Skip to content

Data Flow and Caching — Item-Cards Subsystem

The Arda items page works against a server-side data set that is too large to ship in one response and that mutates while the user is looking at it. The SPA addresses both problems with the same shape: a pull-based grid that loads pages on demand, paired with an in-browser cache for the per-item kanban-card data each visible row depends on. This page is the reference for that subsystem — the item-cards envelope.

The pages that read this reference are Frontend Application Architecture (broader stack context), Editing and Concurrency (what happens when the cached data becomes stale), and Staleness Signal (the mechanism that detects and signals staleness).

Three runtime tiers are involved:

  • Browser SPA — runs the React tree, owns the ItemCardsProvider cache, mounts the AG Grid SSRM datasource. Holds no backend credentials.
  • Next.js BFF — terminates the user’s JWT, owns ARDA_API_KEY, forwards each call to the right backend service with X-Tenant-Id and X-Author headers attached.
  • Backend (operations) — owns the canonical item and kanban-card data. Returns paged results envelopes; never pushes to the SPA.

All requests in this section cross the boundary as POST /api/arda/kanban/kanban-card/query (cards) or POST /api/arda/items/query (items). The SPA never speaks to the backend service URL directly.

The SPA reads card data through two paths. They share the same BFF endpoint and the same cache, but they enter the system at different surfaces.

The items page renders rows through AG Grid’s Server-Side Row Model (SSRM). When the user scrolls or opens the page, AG Grid asks the datasource for a block of rows (a contiguous page window). The datasource fetches the matching items from the BFF, then issues one batched call to cardsForItems for every item id in the block — populating the ItemCardsProvider cache for all rows the user is about to see.

The sequence below shows the grid block load. The grid asks for a block, the datasource fetches the page of items, and a single batched cards query populates the cache for every item id in that block before AG Grid renders the block.

PlantUML diagram

Two properties of this path matter for the rest of the section:

  • The single batched cardsForItems call replaces what would otherwise be one per-row network call per visible item. The cache is keyed by item entity id, so subsequent same-block reads (cell hover, column visibility change) resolve from memory.
  • AG Grid retains its own in-grid block cache. SSRM block eviction is configured through the grid props; the ItemCardsProvider cache is independent and persists across block evictions.

When the user opens the item-details panel for a single item, the panel mounts the useFreshRead hook. The hook does two things in the same effect: it snapshots whatever card data is already in the provider cache for that item id, and it fires a fresh refreshCardsForItem call. When the refresh resolves, the hook compares the resolved rId set against the snapshot to decide whether the user is looking at out-of-date data.

The sequence below shows that read. The mount snapshot and the resolved fetch both pass through ItemCardsProvider’s public refresh contract; the diff at the end is what useFreshRead uses to derive its isStale flag.

PlantUML diagram

The same cache feeds both paths. Path A populates it in batch on grid block load; Path B reads from it and refreshes on detail-panel mount. The detail panel does not bypass the cache; it goes through the provider so that the in-flight refresh can be deduped and the result can be observed by every other subscriber of the same item id.

The SPA sends a filter-by-in request. The locator selects the field on the kanban card to match; the values array holds the item entity ids the SPA wants cards for. Pagination is bounded by a server-side page-size cap.

{
"filter": {
"in": {
"locator": "ITEM_REFERENCE_entity_id",
"values": ["item-001", "item-002", "item-003"]
}
},
"paginate": {
"index": 0,
"size": 100
}
}

The backend returns the standard Arda envelope: an ok boolean, an HTTP-aligned status integer, and a data payload. For card queries the payload is a single page; nextPage repeats the current page id when the query exhausted the dataset.

{
"ok": true,
"status": 200,
"data": {
"thisPage": "0",
"nextPage": "0",
"previousPage": "0",
"results": [
{
"rId": "card-record-1",
"asOf": { "effective": 1700000000, "recorded": 1700000000 },
"author": "operator@example.com",
"retired": false,
"metadata": { "tenantId": "tenant-1" },
"payload": {
"eId": "card-1",
"rId": "card-record-1",
"lookupUrlId": "...",
"serialNumber": "SN-001",
"item": { "type": "ITEM", "eId": "item-001", "name": "Widget" },
"itemDetails": { "/* item snapshot */": "..." },
"status": "REQUESTING",
"printStatus": "PRINTED"
}
}
]
}
}

Two fields are load-bearing for the rest of the user-interface section: rId is the bitemporal record identifier of the kanban card (changes every time the card is mutated server-side), and payload.item.eId is the item entity id this card belongs to. The SPA filters cards by item entity id; it diffs them by rId. See Conflict Resolution for why the diff is on the wrapper rId and not on payload.eId.

ItemCardsProvider is the React context that owns the in-browser cache. Every consumer reaches it through useItemCards() (public surface) or useItemCardsInternal() (subscriber-side surface used by the useFreshRead and useStaleCheck hooks).

The provider holds an entry-keyed map:

type ItemCardsEntry = {
cards: readonly KanbanCardResult[];
fetchedAt: number; // epoch ms
};
type ItemCardsMap = Record<string /* item entity id */, ItemCardsEntry>;

Entries are written by the provider’s internal doFreshFetch helper after a successful BFF round-trip. The fetchedAt timestamp drives the TTL check; it is read but never written by callers.

The methods callers reach through useItemCards() form the contract every consumer relies on:

MethodPurpose
getCards(eid)Synchronous point read. Returns the cached card list for an item entity id or undefined.
hasCards(eid)Synchronous existence check. Returns true when an entry exists, even if empty.
ensureCardsForItem(eid) / ensureCardsForItems(eids)Demand fetch. Returns the existing cache entry when present; otherwise fetches and populates. In-flight requests are deduped per eid so concurrent callers share one BFF call.
refreshCardsForItem(eid) / refreshCardsForItems(eids)Unconditional fetch. Always issues a new BFF call and overwrites the cache entry. Used by the freshness machinery, never by routine reads.
markItemStale(eid | eids)Publish to the local invalidation bus. See Staleness Signal. undefined and empty inputs are no-ops, so callers can pass partial state without pre-guarding.
markItemStaleRemoteOnly(...)Cross-tab-only variant of markItemStale. See Staleness Signal.

ensure* and refresh* look similar but differ on the cache contract:

  • ensureCardsForItems returns existing entries when the cache already has them; it only fetches the eids that are absent (or are mid-flight, which it awaits). It is the right call when the caller wants data and does not need it to be the most recent server state.
  • refreshCardsForItems bypasses the cache check entirely. Every call produces a new BFF round-trip. It is the right call when the caller is reacting to a signal that the cache may be out of date (an explicit user refresh, a focus event, a poll tick).

The split exists so the cache can serve thousands of grid-cell reads cheaply (ensure*) while a small number of freshness-aware paths (refresh*) always reach the source.

The provider stamps fetchedAt on every write. The useStaleCheck hook, mounted by grid cells alongside their card read, computes Date.now() - entry.fetchedAt > ITEM_CARDS_TTL_MS (default 30 s) and, when true, enqueues a background refresh through the provider’s microtask coalescer:

  • Every enqueueStaleRefresh(eid) call within the same tick adds the eid to a queue rather than firing immediately.
  • A single queueMicrotask flush drains the queue with one batched refreshCardsForItems(eids) call, regardless of how many grid cells observed staleness simultaneously.
  • Stale-while-revalidate: the consumer keeps rendering its current cards until the refresh lands; the next render after the batched response picks up the fresh data.

The coalescer’s job is to make the TTL check cheap to mount on every visible cell. The TTL itself is a freshness floor, not a staleness signal — the higher-precision signal is documented in Editing and Concurrency.

useFreshRead and useStaleCheck both register their active item entity id in a per-provider mountedEids set on mount and unregister on cleanup. The set is the truth for “which items currently have a subscriber on this tab”, and is read by the focus / visibility and poll-driven refresh paths described in Staleness Signal. Registration is reference-counted on the provider side, so multiple subscribers on the same eid coexist without stepping on each other; the entry is removed when the last subscriber unmounts.

The shared registration hook is useMountedRegistration(eid). Hooks that need any other interaction with the provider continue to use useItemCards() / useItemCardsInternal(); only the registration lifecycle is centralised.

A single ItemCardsProvider instance is mounted in the root layout of the items pages. Every subscriber on the same tab shares one cache. Sibling browser tabs each run their own provider — they coordinate through the cross-tab signal described in Staleness Signal, not by sharing memory.

Two environment variables tune the cache behaviour without code changes. They are read at module evaluation time on the SPA:

VariableDefaultEffect
NEXT_PUBLIC_ITEM_CARDS_TTL_MS30_000Read-side freshness floor used by useStaleCheck. Lower values produce more background refreshes; higher values let stale-while-revalidate hold out-of-date data on screen for longer.
NEXT_PUBLIC_ITEM_CARDS_POLL_MS120_000Poll interval used by the cross-process staleness path. Any non-positive value disables polling (the operational kill switch). See Staleness Signal.

Both values bake into the Next.js bundle at build time. Changing them requires redeploying the SPA.

The cache contract has two non-obvious failure modes worth knowing about:

  • Fetch failure is opaque to the consumer. cardsForItems resolves to null on transport failure, non-OK response, or data.ok === false. The provider treats those as a fetched-failed sentinel: the cache entry is not advanced, and downstream freshness logic (useFreshRead’s diff) treats the absence of a verdict as “no information” rather than “no cards” — so it does not flicker the staleness banner on a transient error.
  • Auth-token absence short-circuits before the BFF call. The SPA reads the user’s JWT from localStorage and bails out before issuing the request when it is missing. The BFF therefore never sees the request, the cache is untouched, and the consumer is left rendering whatever it had before. Auth-error UX is owned by the auth layer described in Frontend Application Architecture.

The freshness machinery layered on top of the cache — when it fires, what it tells the user, and how it avoids flicker on the failure modes above — is documented in Editing and Concurrency.