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).
Boundaries
Section titled “Boundaries”Three runtime tiers are involved:
- Browser SPA — runs the React tree, owns the
ItemCardsProvidercache, 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 withX-Tenant-IdandX-Authorheaders 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.
Two read paths
Section titled “Two read paths”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.
Path A — SSRM grid block load
Section titled “Path A — SSRM grid block load”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.
Two properties of this path matter for the rest of the section:
- The single batched
cardsForItemscall 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
ItemCardsProvidercache is independent and persists across block evictions.
Path B — Detail-panel read
Section titled “Path B — Detail-panel read”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.
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.
Request and response shapes
Section titled “Request and response shapes”Cards query — request
Section titled “Cards query — request”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 }}Cards query — response envelope
Section titled “Cards query — response envelope”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 — cache shape
Section titled “ItemCardsProvider — cache shape”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).
Storage
Section titled “Storage”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.
Public surface
Section titled “Public surface”The methods callers reach through useItemCards() form the contract every consumer relies on:
| Method | Purpose |
|---|---|
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. |
Refresh semantics
Section titled “Refresh semantics”ensure* and refresh* look similar but differ on the cache contract:
ensureCardsForItemsreturns 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.refreshCardsForItemsbypasses 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.
TTL and read-side coalescing
Section titled “TTL and read-side coalescing”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
queueMicrotaskflush drains the queue with one batchedrefreshCardsForItems(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.
Mount registry
Section titled “Mount registry”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.
Provider scope
Section titled “Provider scope”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.
Operational tuning
Section titled “Operational tuning”Two environment variables tune the cache behaviour without code changes. They are read at module evaluation time on the SPA:
| Variable | Default | Effect |
|---|---|---|
NEXT_PUBLIC_ITEM_CARDS_TTL_MS | 30_000 | Read-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_MS | 120_000 | Poll 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.
Failure modes
Section titled “Failure modes”The cache contract has two non-obvious failure modes worth knowing about:
- Fetch failure is opaque to the consumer.
cardsForItemsresolves tonullon transport failure, non-OK response, ordata.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
localStorageand 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.
Copyright: © Arda Systems 2025-2026, All rights reserved