# ACF Page Cache Design

Audience: AI coding agents first.

## Outcome

Make ACF Fields admin route use canonical page-loading stack. Cold visit MUST paint route skeleton first. Warm visit MUST paint valid memory/localStorage snapshot immediately. REST MUST serve permission-safe WordPress snapshot when eligible. Browser and server MUST refresh stale data lazily without replacing visible data with loading UI. Successful policy mutations MUST invalidate both layers and reconcile authoritative graph.

## Existing Baseline

Reuse shipped seams. NEVER add page-specific cache, localStorage key, polling loop, or refresh scheduler.

- `admin/src/main.js`: route shell and ACF skeleton.
- `admin/src/utils/DataStore.js`: scoped memory/localStorage stale-while-revalidate.
- `admin/src/data/adminStores.js`: policy-bound stores and mutation-tag invalidation.
- `admin/src/components/DataRegion.js`: skeleton-first cached paint, deferred revalidation, keyed reconciliation.
- `includes/Performance/AdminDataPolicy.php`: canonical browser/server cache policy.
- `includes/API/RestResponseCache.php`: permission-safe WordPress REST snapshots and deferred refresh.
- `includes/Performance/CacheInvalidation.php`: generation-token invalidation.

Current `admin/src/pages/fields-translate.js` bypasses stack with direct `API.request()` and `cache: 'no-store'`. Every mount rebuilds ACF graph synchronously. Mutations reload whole graph through same uncached path.

## Scope

Change only ACF Fields page read lifecycle, cache policy, invalidation registration, and focused tests. Preserve:

- Existing accordion/table semantics and lazy disclosure mounting.
- Search snapshot and disclosure restoration.
- Focus, scroll, mutation queue, rollback, and overflow observer behavior.
- Existing REST authorization and response schema.
- Existing 120-second soft / 300-second hard server freshness profile.
- Existing five-minute browser freshness profile.

Do not change ACF policy meaning, graph schema, visual design, or unrelated routes.

## Approaches

### Approach 1: Canonical Policy Store + DataRegion

Register ACF graph in `AdminDataPolicy`; consume through `adminStores` and `DataRegion`; enable existing server stale-while-revalidate route support.

| Dimension | Assessment |
|---|---|
| Robustness | Reuses validated identity, persistence, invalidation, retry, teardown, and authorization behavior. |
| Long-term | One policy and one lifecycle shared with other admin pages. |
| Scalability | Browser and server snapshots suppress repeated graph construction; refresh leases prevent stampede. |
| Performance | Skeleton or cached graph paints before network; visible data survives background refresh. |
| Reversibility | Two-way door; remove descriptor and page binding to restore uncached behavior. |
| Infra cost | No new service or storage system. |

**Weakness:** Page renderer must expose a keyed DataRegion-compatible seam without losing accordion-specific state.

### Approach 2: Canonical Store + Bespoke Page Lifecycle

Register policy and consume `DataStore`, but retain manual page subscription, skeleton, refresh, and error handling.

| Dimension | Assessment |
|---|---|
| Robustness | Cache identity remains canonical, but lifecycle can drift from shared behavior. |
| Long-term | Duplicates loading, stale, retry, teardown, and reconciliation logic. |
| Scalability | Same cache benefits as Approach 1. |
| Performance | Can meet latency target if bespoke scheduling remains correct. |
| Reversibility | Two-way door. |
| Infra cost | No new service; higher maintenance cost. |

**Weakness:** Violates requirement to use same primitives and recreates solved lifecycle code.

**Recommended: Approach 1.** Existing primitives already implement required sequence and failure handling. Binding ACF page to them is smaller and safer than maintaining a parallel lifecycle.

## Policy Contract

Add descriptor key `acf-fields:all`:

- endpoint: `/translations/fields`
- method: GET
- query fields: none
- browser TTL: `300000` milliseconds
- browser persistence: enabled
- server scope: user
- server soft TTL: `120` seconds
- server hard TTL: `300` seconds
- invalidation tag: `acf-fields`
- live fields: none

Register mutation tags:

- `PATCH /translations/fields/default` invalidates `acf-fields`.
- `PATCH /translations/fields/{field_key}` invalidates `acf-fields`.

Add `/international-press-zone/v1/translations/fields` to existing bounded deferred-refresh eligibility. Do not broaden eligibility for search, detail, or unrelated routes.

## Page Seam

`FieldsTranslatePage` MUST obtain graph through canonical `getDataStore('acf-fields:all')` binding and mount it through one `DataRegion`.

Add an optional `validate(data): data` store-boundary contract to `DataStore`/`adminStores`. ACF descriptor MUST supply existing graph validator through that seam. Validation MUST run before accepting either hydrated localStorage data or network data and before persistence/notification. Validation failure MUST evict malformed persisted entry or reject network revalidation without replacing last valid data. Existing stores without validator retain current behavior.

Region item identity MUST be stable and independent of response object identity. Select graph as one stable `acf-fields-graph` item. View contract:

- `keyOf()` returns constant graph identity.
- `renderItem()` creates current page shell and graph once.
- `updateItem()` performs keyed graph reconciliation; it MUST NOT call current subtree-clearing `paint()`.
- `removeItem()` disconnects graph-owned overflow observers/listeners.
- `isBusy()` reports queued mutation or active ACF control interaction so DataRegion defers DOM apply.

Keyed graph reconciliation MUST preserve settings controls, group disclosures, nested field/layout disclosures, and unchanged DOM nodes by existing ACF keys. Removed focused key MUST move focus to owning surviving summary or page heading. Existing ACF field/group keys remain disclosure and focus identities.

Lifecycle:

1. Route shell paints ACF skeleton before lazy module import resolves.
2. Region retains skeleton while no valid snapshot exists.
3. Valid memory/localStorage snapshot paints synchronously on mount.
4. Ordinary GET resolves from WordPress cache or live controller.
5. Cached graph remains visible during deferred browser/server revalidation.
6. Changed authoritative graph reconciles by stable keys; unchanged payload MUST NOT replace DOM.
7. Teardown MUST abort subscriptions, refreshes, timers, and observers.

Never issue `cache: 'no-store'` for graph GET. Never add route-specific polling or cache bypass.

## Mutation Contract

Keep direct PATCH requests and current serialized mutation behavior.

On success:

1. Server mutation completes.
2. Canonical mutation tags rotate server generation and invalidate browser stores.
3. Page joins store's single shared in-flight authoritative request; it MUST NOT launch a duplicate reload.
4. Mutated controls remain busy until authoritative reconciliation completes.
5. Reconciliation preserves disclosure snapshot, query, scroll, logical focus, and observer lifecycle.

On failure:

- Restore changed control explicitly from captured pre-mutation value even when cached and returned payloads compare equal.
- Keep pre-write authoritative graph.
- Preserve disclosure/query/scroll/focus.
- Surface existing actionable error.
- Never persist optimistic state as authoritative cache data.

Race contract:

- Invalidation aborts or generations out every older graph GET before authoritative mutation refresh begins.
- Older GET completion MUST NOT repopulate browser or server cache after generation rotation.
- Serialized PATCH operations each reconcile against latest authoritative generation.
- Destroy/remount MUST leave no listener, timer, request, or observer able to mutate detached page.

## Writer Invalidation

Rotate `acf-fields` only after successful committed writes from:

- Both ACF policy REST PATCH routes.
- Global field-policy option updates.
- Native ACF field-group/field create, update, trash/delete, and relevant local-JSON synchronization hooks.
- ACF activation/deactivation or availability transition when graph response changes.

Use narrow ACF lifecycle hooks; NEVER invalidate on unrelated post saves. PHP-registered field/schema changes without a runtime write have no universal event; bounded hard TTL is explicit fallback for those out-of-band changes. Document hook coverage in tests and code through existing invalidation seams, not a second registry.

## Failure Contract

- Invalid localStorage identity/schema/JSON: discard and continue cold path.
- Cold GET failure: show current retryable error view.
- Background GET failure: retain graph; expose retry without skeleton regression.
- Malformed graph: reject before persistence/render and show current malformed-state error.
- Cache read/write failure: preserve API correctness; live route remains fallback.
- Server stale refresh failure: retain bounded stale snapshot until hard expiry.
- Hard-expired live failure: return live error; never serve beyond hard TTL.
- Authorization failure: never cache or replay response.

## Testing Contract

### Admin unit

Prove:

- Route skeleton exists before unresolved cold graph request.
- Fresh memory snapshot paints without blocking on request.
- Valid localStorage snapshot paints before deferred request.
- Stale browser snapshot paints first, then revalidates after paint.
- `X-IPZ-Cache: stale` uses shared bounded follow-up behavior.
- Unchanged response preserves DOM identity.
- Changed response preserves keyed disclosures, search snapshot, scroll, logical focus, and observer cleanup.
- Cold error is retryable; background error keeps graph visible.
- Malformed response is not persisted or rendered.
- Successful PATCH invalidates and joins one authoritative reload; no duplicate GET.
- Failed PATCH restores existing state, including equal-payload explicit select rollback.
- GET-in-flight during PATCH cannot repaint old generation.
- Two serialized PATCH operations reconcile in order.
- Cross-tab invalidation removes persisted graph and triggers canonical refresh.
- Destroy/remount during refresh leaves zero listeners, timers, requests, or observers.
- Focused control survives refresh; deleted focused target falls back deterministically.
- Active search, nested disclosure identity, and overflow observers survive graph replacement.

### PHP unit

Prove:

- Exact ACF descriptor, TTLs, scope, tags, and mutation mappings localize correctly.
- GET is cache eligible only after permission callback.
- Fresh hit bypasses graph builder.
- Soft-stale hit replays snapshot and schedules bounded refresh.
- Hard miss executes graph builder.
- User/site/locale/version/schema/generation identity remains partitioned.
- Both PATCH routes make pre-mutation snapshot unreachable after success and preserve it after failure.
- Native ACF field/group create, update, delete/trash, local-JSON synchronization, global-option update, and ACF availability transitions rotate exact tag.
- Concurrent old-generation GET cannot write after mutation rotation.
- Errors, unsafe headers, nonce-bearing requests, and non-GET requests never store.

### Browser

Run Chromium and Firefox only through canonical IPZ E2E skill and `e2e-remote` against authorized self-contained local/buildbox WordPress. Never target dev1. Deterministically assert skeleton → memory/localStorage → WordPress hit/stale/miss transitions, request counts, warm navigation without skeleton regression, visible-data retention during refresh, stable accordion/search/focus behavior, and mutation invalidation. Screenshots alone are insufficient.

## Architecture Decisions

- Keep `AdminDataPolicy` canonical. Deleting it would split browser/server TTL, identity, and invalidation rules.
- Keep `DataStore` and `DataRegion`. Deleting either would scatter persistence or UI lifecycle complexity into page code.
- Keep `RestResponseCache`. Deleting it would rerun graph construction and duplicate permission-safe replay logic.
- Collapse no modules. Every reused boundary hides non-trivial behavior and has multiple consumers.
- Reject bespoke page lifecycle. It is a shallow duplicate of `DataRegion` with no independent value.
