Cursor cache mechanism

Why it exists

The phone-data response is large — 50–200 KB common, larger for sites with many forms or content classes. Re-sending the entire response on every cold app launch would be wasteful (mobile data, app start latency) and pointless (most sections rarely change).

The cursor mechanism lets the app skip the entire rebuild when nothing on the site has changed since its last successful request.


How it works

The cursor is a single global token (a uniqid() string), not a per-section or per-user timestamp map. Comparison is all-or-nothing:

1. The server holds one global cursor in the `mam_json_cursor` option
   (an opaque uniqid string, bumped whenever site content changes).

2. On a successful build, the mobile app stores the `cursor` value
   returned in the response.

3. On the next request, mobile sends `?cursor=<token>`.

4. In phase_settings the pipeline compares:
     - `$_REQUEST['cursor'] == JSON_Cursor_Manager::get_cursor()`
       → short-circuit the WHOLE response with
         `{ json_status: 'no_new_data', cursor: <same> }`
         (the app keeps its entire cached payload; phase_content is skipped)
     - otherwise → build the full payload; the response carries the
       current `cursor` and `json_status = 'new_data'`.

5. When any watched change occurs, `JSON_Cursor_Manager::update_cursor()`
   (or `reset_cursor()`) writes a new uniqid, invalidating every app's cache.

JSON_Cursor_Manager

includes/app-connect/json-cursor-manager.php owns the cursor state. It stores a single value in the mam_json_cursor option (const OPTION_KEY = 'mam_json_cursor'). All methods are static:

Method Use
get_cursor() Read the current global cursor value (defaults to 0 when unset)
update_cursor() Bump the cursor to a fresh uniqid() — invalidates all client caches
reset_cursor() Identical to update_cursor() — writes a fresh uniqid()
setup_hooks() Registers the WP hooks that auto-bump the cursor on content changes

The cursor is global, not per-user — one option value shared by every app. There is no per-section timestamp map and no set_cursor / should_skip_section method.


When the cursor is bumped

JSON_Cursor_Manager::setup_hooks() wires update_cursor() to a broad set of WordPress content hooks, so most ordinary edits invalidate the cache automatically:

  • save_post, deleted_post
  • added_post_meta, updated_post_meta, deleted_post_meta
  • user_register, deleted_user
  • added_user_meta, updated_user_meta, deleted_user_meta
  • created_term, edited_term, delete_term

In addition, reset_cursor() is called explicitly from the app-settings save paths (app-settings-data-manager.php — button/layout/settings saves) and from phase_settings when a role has tsl-setting-disable_caching = yes.

⚠️ Because the cursor is global, any of these bumps forces every app to do a full rebuild on its next request. This is intentional (content changes should propagate) but means a busy editing session keeps the cache mostly cold.


Forcing a fresh build

phase_settings reads the role-aware tsl-setting-disable_caching setting via apply_filters( 'mam_app_settings_get_setting', 'no', $user_role, 'general-settings', 'tsl-setting-disable_caching' ). When it resolves to yes, the pipeline calls JSON_Cursor_Manager::reset_cursor() before reading the server cursor — so the caller’s stored cursor can never match and the full payload is always rebuilt. Useful for admin previews and debugging; expensive if left on in production.


No per-section granularity

The cursor is deliberately coarse: there is one global token, and a cursor match short-circuits the entire response. The pipeline does not keep a per-section timestamp map, and there is no supported way for a sibling plugin to serve just its own section as “unchanged” while the rest rebuilds. Either the whole payload is rebuilt (cursor mismatch) or the whole payload is skipped (cursor match).

If a sibling plugin’s data changes and it wants apps to pick that up, it should cause the global cursor to bump — the easiest path is that its writes go through save_post / post-meta / term hooks (already wired in setup_hooks()), or it can call JSON_Cursor_Manager::update_cursor() directly after mutating its data.


Gotchas

  • reset_cursor() is a hammer. It invalidates the cache for every app, not just the current user. Use sparingly.
  • The cursor is global, not per-user. A single content edit anywhere on the site forces every app to rebuild on its next request.
  • Section-root null is not a cursor signal. The response never selectively nulls sections for delta purposes — that per-section model does not exist. null inside field values is a separate concern handled by the post-pipeline mam_replace_null_with_empty_string().
  • No cross-process cursor coordination. Two simultaneous requests from the same device can both build (rare in practice; mobile clients serialize).

  • Phone data pipeline overview
  • Phone data pipeline phases
  • Mobile JSON shape
  • Hook: mam_get_phone_data_before_send

Metadata

Field Value
Article type Plugin Overview
Plugin slug mam-main
Applies to plugin version 2.1.11+
Category Plugin Reference
Audience PHP developer
Last verified 2026-05-02
Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!