Skip to content

Design: PDEV-610 Cross-User Staleness Signal

Design: PDEV-610 Cross-User Staleness Signal

Section titled “Design: PDEV-610 Cross-User Staleness Signal”

Today the stale-data banner on the items detail panels fires only for the user who initiated a change, because useFreshRead only flips isStale=true when its own next revalidation observes new rIds. There is no transport — no BroadcastChannel, no SSE, no WebSocket, no polling — that tells a concurrent observer that anything changed. The reported bug (PDEV-610) is therefore a missing-transport gap exposed by the refactor that introduced the banner, not a defect in the banner or in useFreshRead.

This design adds a small local invalidation bus (itemStaleBus) inside arda-frontend-app. The bus has multiple producers — item mutations (in ItemFormPanel and useDeleteItems), card-state events from many surfaces (CardStateDropdown, columnPresets quick actions, scan modals, order-queue, kanban-card pages), a RefreshButton added to ItemDetailsPanel’s banner, the refactored useRefreshOnFocus, and a new pollTimer — and one primary consumer: ItemCardsProvider. The bus’s transport is a BroadcastChannel('arda-item-stale'), which gives same-browser cross-tab propagation for free. The pollTimer carries the cross-process signal (different browsers, different users) within a bounded SLA. The two layers compose: the bus closes the same-browser case at zero latency, and pollTimer closes the cross-browser case at conservative cost.

The fix is frontend-only. The operations component is not modified. A future server-push transport (SSE per DQ-001 rejected alternatives) would plug into the bus as one more producer without touching consumers.

#DecisionChosen Option
DQ-001Transport mechanism for the staleness signalLocal invalidation bus (BroadcastChannel + in-tab notify) + provider-level interval poll
DQ-002Polling interval120 s default, env-tunable via NEXT_PUBLIC_ITEM_CARDS_POLL_MS
DQ-003What the poll fetchesCurrently-mounted eids only (not the full LRU cache)
DQ-004Conditional GET / ETag supportDeferred. Backend does not provide today; revisit if request volume warrants.
DQ-005Which handlers publish to the busItem mutations, kanban-card state events at every triggering surface, manual RefreshButton, refactored useRefreshOnFocus, new pollTimer.
DQ-006Banner placement changesNone. Cross-user staleness banner stays in ItemDetailsPanel only; page-level useBulkSelectionStaleGuard banner unchanged.
DQ-007How markItemStale handles its own tabHelper always calls enqueueStaleRefresh locally and posts to the channel. BroadcastChannel does not echo to sender.
DQ-008Cycle preventionOnly write-originating intents publish. Cache-update completions do not publish.
DQ-009Test strategy for the cross-browser reproMSW stateful handler (returns rId set A on first call, set B on Nth call). No real two-browser harness.
DQ-010Backend (operations) involvementNone for this design.

Full rationale in the inline Decision Log below.


The component diagram below shows the producers that call markItemStale, the bus itself (markItemStale + BroadcastChannel('arda-item-stale')), the ItemCardsProvider internals that consume the signal (channelSubscriber, enqueueStaleRefresh, refreshCardsForItems, itemCardsMap, mountedEids), and the subscribing hooks (useFreshRead, useStaleCheck) that drive StaleDataBanner. Every name in this diagram appears verbatim in the Key Modules section, the sequence diagrams, and the Implementation Scope tables.

PlantUML diagram

Top-level subsections mirror the packages in the component diagram (Producers, itemStaleBus, ItemCardsProvider, Subscribers). Leaf subsections use the element names from the diagram so the reader can grep, and match production code identifiers wherever possible. This section is purely functional — file paths and per-construct change descriptions live in Implementation Artifacts; test-only artifacts are described functionally in Testing Elements.

  • Role in the diagram: the Item mutations producer box.
  • Change: on successful item create / update / delete, call markItemStale(...) with the affected entityIds. Two real entry points exist today: ItemFormPanel’s save flow (calls createItem / updateItem directly) and useDeleteItems’s bulk-delete success path.
  • Design decisions: DQ-005.
  • Role in the diagram: the Card-state events producer box.
  • Change: every surface that triggers a kanban-card state event (request, accept, start-processing, fulfill, unmark) calls markItemStale(affectedEntityIds) on success. There are many such surfaces today: per-row dropdowns, grid quick-actions, the scan modals, the order-queue page, and the kanban-card direct route. The producer wiring is the same in each: post-success, mark the item(s) stale.
  • Design decisions: DQ-005.
RefreshButton (new behavior in ItemDetailsPanel)
Section titled “RefreshButton (new behavior in ItemDetailsPanel)”
  • Role in the diagram: the RefreshButton producer box (hosted by ItemDetailsPanel, the only cross-user-staleness banner host).
  • Change: add RefreshButton to the banner header that calls markItemStale(eid) on click. No change to StaleDataBanner itself. The page-level bulk-selection banner in items/page.tsx is driven by a separate mechanism (useBulkSelectionStaleGuard) and is out of scope.
  • Design decisions: DQ-005, DQ-006.
  • Role in the diagram: the useRefreshOnFocus (refactored) producer box. Lives inside the items page composition; acts as a producer.
  • Change: the hook already exists and already calls refreshCardsForItems directly when the document becomes visible. Refactor it to call markItemStale(eids) instead, with eids = displayed-grid-items ∪ open-panel-item. Behavior is preserved; the bus now sees focus-resume as a uniform producer event so sibling tabs also benefit.
  • Design decisions: DQ-001, DQ-005.
  • Role in the diagram: the pollTimer (new) producer box. Lives inside the ItemCardsProvider package in code but acts as a producer; arrows in the diagram cross the package boundary into markItemStale.
  • Change: setInterval installed on ItemCardsProvider mount, default 120 s, env-tunable via NEXT_PUBLIC_ITEM_CARDS_POLL_MS. On each tick, if document.visibilityState === "visible" and mountedEids is non-empty, calls markItemStale(Array.from(mountedEids)). Disposed on unmount.
  • Design decisions: DQ-001, DQ-002, DQ-003.
  • Role in the diagram: the itemStaleBus (new) package.
  • Responsibility: owns the BroadcastChannel('arda-item-stale') instance and exposes markItemStale. Constructed on ItemCardsProvider mount; disposed on unmount.
  • Public surface:
    • createItemStaleBus(consumer: (eids: string[]) => void): ItemStaleBus — factory.
    • markItemStale(eid: string | readonly string[]): void — calls consumer(eids) synchronously and posts {type: "item-stale", eids} on the channel.
    • dispose(): void — closes the channel.
  • Design decisions: DQ-001, DQ-007.
  • Role in the diagram: the ItemCardsProvider package. Hosts channelSubscriber, enqueueStaleRefresh, refreshCardsForItems, itemCardsMap, and mountedEids. Also owns pollTimer (described as a Producer above) and the itemStaleBus instance.
  • New members:
    • mountedEids: Set<string> — registry of eids currently being observed by useFreshRead / useStaleCheck. Populated by register(eid) / unregister(eid) calls from those hooks. Read by useRefreshOnFocus and pollTimer to decide what to revalidate.
    • channelSubscriberonmessage handler attached to the bus channel; invokes enqueueStaleRefresh(eids).
  • Exposed on context: markItemStale (delegates to the owned itemStaleBus).
  • Unchanged: enqueueStaleRefresh, refreshCardsForItems, itemCardsMap.
  • Design decisions: DQ-001, DQ-008.
  • Role in the diagram: the useFreshRead\nuseStaleCheck subscriber box.
  • Change: in their effect, call register(eid) against the provider’s mountedEids on mount and unregister(eid) on cleanup. The freshness / rId-diff logic is unchanged.
  • Design decisions: DQ-003.
  • Role in the diagram: the StaleDataBanner subscriber box.
  • Change: none. Two production hosts render it today: ItemDetailsPanel (cross-user staleness, the PDEV-610 surface) and items/page.tsx (bulk-selection rId-check via useBulkSelectionStaleGuard, a separate mechanism untouched by this design).
  • Design decisions: DQ-006.

Behaviors are grouped from the inside out: bus mechanics, producer wiring, registry, consumer, gating, end-to-end, robustness. Each named behavior cross-links to the Key Elements subsection that owns the responsible piece. Each behavior maps to one or more entries in Behavior Verification.

Behaviors of the bus regardless of who calls it. Owner: itemStaleBus.

  • Local notify. markItemStale(eids) calls the registered consumer synchronously, in the same tab, before posting to the channel. See itemStaleBus.
  • Cross-tab post. markItemStale(eids) posts {type: "item-stale", eids} on BroadcastChannel('arda-item-stale'). See itemStaleBus.
  • Self-tab compensation. The publishing tab does not receive its own postMessage; the helper’s local-notify branch covers this case (per DQ-007). See itemStaleBus.
  • Input normalization. markItemStale("X") and markItemStale(["X","Y"]) both reach the consumer as string[]. See itemStaleBus.
  • Dispose. After dispose(), neither inbound delivery nor outbound posts occur. See itemStaleBus.
  • Capability fallback. When typeof BroadcastChannel === "undefined" at module load (older runtimes, some test environments), the bus degrades to in-tab-only delivery without throwing. The cross-browser path via pollTimer still works.

Each producer publishes correctly on the right occasion with the right eids. Owners: the leaf subsections of Producers.

  • ItemFormPanel save. On createItem / updateItem success → markItemStale(resultItem.entityId). See Item mutations.
  • useDeleteItems. On bulk-delete success → markItemStale(deletedEntityIds). See Item mutations.
  • CardStateDropdown. On card-state event success → markItemStale(itemEntityId). See Card-state events.
  • columnPresets quick actions. On event success from a grid quick action → markItemStale(itemEntityId). See Card-state events.
  • ScanModal. On event success → markItemStale(itemEntityId). See Card-state events.
  • CardPreviewModal. On event success → markItemStale(itemEntityId). See Card-state events.
  • MobileScanView. On event success → markItemStale(itemEntityId). See Card-state events.
  • DesktopScanView. On event success → markItemStale(itemEntityId). See Card-state events.
  • order-queue page. On event success → markItemStale(itemEntityId). See Card-state events.
  • kanban-card page. On event success → markItemStale(itemEntityId). See Card-state events.
  • OrderSidebar. On event success → markItemStale(itemEntityId). See Card-state events.
  • RefreshButton click. On click in ItemDetailsPanel’s banner header → markItemStale(item.eid). See RefreshButton.
  • useRefreshOnFocus on visible transition. On visibilitychange → visiblemarkItemStale(displayed-grid-items ∪ open-panel-item). See useRefreshOnFocus.
  • pollTimer tick. When gating conditions hold (see §5), the tick → markItemStale(Array.from(mountedEids)). See pollTimer.

The eid set read by focus-resume and the poll must reflect actual subscribers. Owners: ItemCardsProvider and useFreshRead, useStaleCheck.

  • useFreshRead registration. Mount → eid present in mountedEids; unmount → eid removed. See useFreshRead, useStaleCheck.
  • useStaleCheck registration. Same discipline as useFreshRead. See useFreshRead, useStaleCheck.
  • eid change on a live subscriber. Old eid removed, new eid added; no stale residue. See useFreshRead, useStaleCheck.
  • Multiple subscribers on the same eid. eid is present while any subscriber is mounted; removed only when the last unmounts (reference-counted, or accept that re-add on existing key is idempotent). See ItemCardsProvider.

ItemCardsProvider turns bus signals into batched refreshes. Owner: ItemCardsProvider.

  • channelSubscriber → enqueue. An inbound channel message invokes enqueueStaleRefresh(eids). See ItemCardsProvider.
  • In-tab notify → enqueue. Local markItemStale (no channel hop) also reaches enqueueStaleRefresh. See ItemCardsProvider.
  • Microtask coalescing. Multiple markItemStale calls within one JS tick collapse into a single batched refreshCardsForItems(eids) call (existing coalescer; this design must not regress it). See ItemCardsProvider.
  • Context-exposed markItemStale. Consumers reading useItemCards() get markItemStale that delegates to the owned bus. See ItemCardsProvider.

Conditions under which producers correctly do nothing — the system stays cheap when nothing useful would happen.

  • pollTimer skipped while hidden. When document.visibilityState !== "visible", the tick is a no-op. See pollTimer.
  • pollTimer no-op on empty registry. When mountedEids is empty, the tick is a no-op. Zero background traffic when nothing is open. See pollTimer.
  • useRefreshOnFocus no-op on empty eid set. When there are no displayed items and no open panel item, the focus handler is a no-op. See useRefreshOnFocus.
  • pollTimer disposed on unmount. No leaked intervals after ItemCardsProvider unmount. See pollTimer and ItemCardsProvider.

Two real-world scenarios that exercise the full chain — diagrams below. A third in-tab behavior (writer’s own UI) is described in prose at the end.

Same-browser cross-tab via BroadcastChannel

Section titled “Same-browser cross-tab via BroadcastChannel”

A single user has the same item open in two tabs of the same browser. Tab A saves a change; markItemStale updates Tab A’s cache via enqueueStaleRefresh and posts on the bus channel. Tab B’s channelSubscriber receives the message, runs enqueueStaleRefresh, calls refreshCardsForItems, and Tab B’s useFreshRead flips isStale so StaleDataBanner appears. End-to-end latency is dominated by the second fetch, typically well under one second.

PlantUML diagram

Two users in separate browsers see the same item. There is no BroadcastChannel path between them — the only signal that User A’s edit can reach User B is User B’s own pollTimer. The diagram walks through four phases: both users set up; Browser B’s pollTimer fires before User A’s edit (no change observed, banner stays hidden); User A saves through Item mutations, and the change propagates Browser A → BFF → operations so the backend now serves rId set S1; Browser B’s next pollTimer tick fetches the new set, useFreshRead diffs S0 vs S1, and StaleDataBanner becomes visible. SLA: ≤ 120 s under default settings (per DQ-002); ≤ ~2 × interval in the worst case (a tick fires just before A’s write commits).

PlantUML diagram

After a write originating in the same tab, the writer’s useFreshRead flips isStale when the next refresh resolves with a different rId set, and clears it when the writer’s own action snapshots and matches again. The writer’s UI does not lag behind their own action. Latency: dominated by the round-trip of the mutation’s follow-up refresh. No additional diagram — see §4 Consumer wiring for the local notify path.

Failure modes and interaction with adjacent mechanisms.

  • Refresh failure does not flicker the banner. When refreshCardsForItems resolves to the fetch-failed sentinel (transport / 5xx), useFreshRead leaves isStale unchanged. The banner does not flicker on transient errors.
  • Cycle safety. Cache-update completions do not publish to the bus; only write-originating intents do (per DQ-008). No amplification under sustained load. See itemStaleBus and ItemCardsProvider.
  • useBulkSelectionStaleGuard coexistence. The pre-flight rId-check for bulk delete and its page-level banner are unaffected by bus traffic; the guard’s snapshot/diff still runs as before (see the non-interference note in DQ-005).
  • refreshItemCards window-event coexistence. ItemDetailsPanel’s existing window-event listener and the new bus can both fire for the same underlying event without producing a double-refresh storm — the microtask coalescer in ItemCardsProvider absorbs the burst.

Subsections mirror Behavioral Design one-for-one. Each subsection carries up to three tables — Unit, Integration, E2E — and lists only the levels that apply. Test identifiers follow BV-<group>-<seq>. The Test Fixtures column references the functional descriptions in Testing Elements. Rows are consolidated where the test shape is identical across surfaces.

Test IDBehavior TestedRequired SetupTest Fixtures
BV-1-01Local notifymarkItemStale calls the consumer synchronouslyInstantiate bus with a stub consumer; call markItemStale("X") and markItemStale(["X","Y"])Stub bus consumer
BV-1-02Cross-tab postmarkItemStale posts on the channelSpy on postMessage of a polyfilled BroadcastChannel; call markItemStaleBroadcastChannel test polyfill, Stub bus consumer
BV-1-03Self-tab compensation — sender does not receive its own post; helper covers via local notifyTwo bus instances on one polyfilled channel; assert sender’s consumer fires via local path, receiver’s via the channelBroadcastChannel test polyfill, Stub bus consumer
BV-1-04Input normalizationstring and string[] both deliver as string[]Bus + stub consumer; call with each input shape; assert delivered shapeStub bus consumer
BV-1-05Dispose — after dispose(), no further inbound delivery or outbound postsBus + spy; call dispose(); further markItemStale calls and channel posts are no-opsBroadcastChannel test polyfill, Stub bus consumer
BV-1-06Capability fallback — when BroadcastChannel is undefined, in-tab-only delivery without throwingOverride globalThis.BroadcastChannel to undefined for the testStub bus consumer
Test IDBehavior TestedRequired SetupTest Fixtures
BV-2-01ItemFormPanel savecreateItem / updateItem success → markItemStale(entityId)Render ItemFormPanel; mock ardaClient.createItem / updateItem to succeed; spy markItemStale on the contextRender-with-providers helpers
BV-2-02useDeleteItems — success path → markItemStale(deletedEntityIds)Hook test with mock context; resolve delete promise; assert spyRender-with-providers helpers
BV-2-03Card-state events — each surface publishes markItemStale(itemEntityId) on event success. One concrete test per real producer surface, totalling 11 tests across 5 files: columnPresets QuickActionsCell shopping-cart (1) + CardStateDropdown state-change success (1) + ScanModal add-to-queue + receive (2) + CardPreviewModal add-to-queue + receive (2) + order-queue/page handlers handleStartOrder / handleSendEmail / handleCopyToClipboard / handleCompleteOrder / handleRemoveFromQueue (5). Tests live near the user-interaction surface: at the component for modal / dropdown / cell surfaces (where page-level tests already mock that boundary), at the page for the order-queue handlers.For each surface: render with the appropriate harness (buildItemCardsContextMockModule for the spy); mock the event endpoint via global.fetch to succeed; click the user-trigger; assert markItemStale was called with the resolved itemEntityId.Render-with-providers helpers
BV-2-04RefreshButton click — click in ItemDetailsPanel banner → markItemStale(item.eid)Render ItemDetailsPanel with isStale=true; click the button; spyRender-with-providers helpers
BV-2-05useRefreshOnFocus — on visible transition, calls markItemStale(displayed ∪ open-panel-item)Mount the hook with a stub grid ref + open-panel item; dispatch visibilitychange → visible; spyRender-with-providers helpers, Visibility / focus event helpers
BV-2-06pollTimer tick — under gating, the tick calls markItemStale(Array.from(mountedEids))Mount provider with one subscribed eid; advance fake timer past the interval; spyRender-with-providers helpers, Fake timers
Test IDBehavior TestedRequired SetupTest Fixtures
BV-3-01useFreshRead / useStaleCheck register/unregister — mount adds eid, unmount removes itRender either hook; assert mountedEids via a test-only accessor on the provider; unmountRender-with-providers helpers
BV-3-02eid change on a live subscriber — old eid removed, new eid addedRender with eid X; rerender with eid Y; assert registryRender-with-providers helpers
BV-3-03Multiple subscribers on same eid — eid stays until the last unmounts (or re-add is idempotent)Render two consumers of the same eid; unmount one; assert still present; unmount the lastRender-with-providers helpers
Test IDBehavior TestedRequired SetupTest Fixtures
BV-4-01channelSubscriber → enqueue — inbound channel message invokes enqueueStaleRefresh(eids)Mount provider; post on the polyfilled channel; spy enqueueStaleRefreshBroadcastChannel test polyfill, Render-with-providers helpers
BV-4-02In-tab notify → enqueue — context markItemStale reaches enqueueStaleRefreshMount provider; call the exposed markItemStale; spyRender-with-providers helpers
BV-4-03Microtask coalescing — many markItemStale calls in one tick → single batched refreshCardsForItemsSpy refreshCardsForItems; fire several calls synchronously; assert a single batched call after the microtaskRender-with-providers helpers
BV-4-04Context-exposed markItemStaleuseItemCards() returns a function that delegates to the busRender; call via context; assert bus’s markItemStale was invokedRender-with-providers helpers, Stub bus consumer
Test IDBehavior TestedRequired SetupTest Fixtures
BV-5-01pollTimer skipped while hiddenvisibilityState !== "visible" → no-opStub document.visibilityState to "hidden"; advance fake timer; assert no callFake timers, Visibility / focus event helpers
BV-5-02pollTimer no-op on empty registry — empty mountedEids → no-opProvider with no subscribers; advance fake timer; assert no callFake timers, Render-with-providers helpers
BV-5-03useRefreshOnFocus no-op on empty eid setHook with empty grid + no open-panel item; dispatch visibility; assert no callVisibility / focus event helpers, Render-with-providers helpers
BV-5-04pollTimer disposed on unmount — no leaked intervalsMount provider; unmount; advance fake timer; assert no callFake timers, Render-with-providers helpers

6. End-to-end propagation — verification

Section titled “6. End-to-end propagation — verification”
Test IDBehavior TestedRequired SetupTest Fixtures
BV-6-01Same-browser cross-tab via BroadcastChannel — write in one provider triggers banner in a siblingTwo ItemCardsProvider instances in one jsdom sharing the polyfilled channel; render ItemDetailsPanel on both; mutate via the firstTwo-provider jsdom harness, BroadcastChannel test polyfill, Render-with-providers helpers
BV-6-02Cross-browser via pollTimer — banner appears within one poll cycle without user actionProvider with short poll interval override; stateful MSW handler returning S0 then S1; render ItemDetailsPanel; advance fake timerStateful MSW cards handler, Fake timers, Render-with-providers helpers
BV-6-03In-tab writer feedback — writer’s own UI gets the banner after their mutationSingle provider; render ItemDetailsPanel; trigger a mutation whose follow-up refresh returns a different rId setStateful MSW cards handler, Render-with-providers helpers
Test IDBehavior TestedRequired SetupTest Fixtures
BV-6-04Cross-browser via stateful mock — Playwright observes the banner appear on detail-panel open without any further user action. Implementation note: the stateful handler intercepts only single-eid cardsForItems queries (panel mount-fetch path), letting the items grid’s multi-eid SSRM prefetch populate the cache via the default handler. The mount-fetch then returns a different rId set than the cache, and useFreshRead’s rId-set diff flips isStale. This exercises the same observable surface as the pollTimer path (banner appears without user action) without depending on a fast poll interval.Mock mode; register stateful handler after waitForGridToLoad; navigate to detail; assert bannerPlaywright stateful-mock setup
BV-6-05RefreshButton in detail panel works — Playwright click → banner clears (refresh re-snapshots and adopts the new server state)Mock mode + stateful handler; navigate to detail; wait for banner; click RefreshButton; assert banner clearsPlaywright stateful-mock setup

The cross-browser repro is not exercised against a real two-browser harness. Per DQ-009, the polling path is verified via a stateful mock that simulates a backend whose rId set has changed between two reads.

7. Robustness and coexistence — verification

Section titled “7. Robustness and coexistence — verification”
Test IDBehavior TestedRequired SetupTest Fixtures
BV-7-01Refresh failure does not flicker the bannerisStale unchanged when refresh resolves to the fetch-failed sentinelMock refreshCardsForItems to resolve to the sentinel; mount useFreshRead; assert isStale stays falseRender-with-providers helpers
BV-7-02Cycle safety — cache-update completions do not publish to the busSpy markItemStale; trigger an inbound channel message; assert no further markItemStale is invoked after the refresh resolvesRender-with-providers helpers, Stub bus consumer
BV-7-03useBulkSelectionStaleGuard non-interference — bulk delete still works under concurrent bus trafficExisting useBulkSelectionStaleGuard tests remain green; add one case where markItemStale is invoked concurrently with the guardRender-with-providers helpers
BV-7-04refreshItemCards window-event coexistence — concurrent window event + bus → no amplification storm. The window-event handler issues its own single refresh, the bus’s microtask coalescer issues a single batched refresh regardless of how many markItemStale calls land in the same tick → exactly two refresh calls total (one per path), never more.Single provider; mount useWindowItemEvents; dispatch refreshItemCards window event and call markItemStale twice for the same eid in one tick; spy refreshCardsForItems; assert exactly two calls, each with the target eidRender-with-providers helpers, Visibility / focus event helpers

Functional descriptions of every test fixture referenced above. File-level wiring lives in Testing Artifacts under Implementation Artifacts.

Four configurations cover all rows in Behavior Verification. Each diagram shows the system under test on the left/center and the opt-in fixtures around it, mirroring the convention used in Structural Design.

The bus runs in isolation: a stub consumer takes the place of ItemCardsProvider, and a polyfilled BroadcastChannel stands in for the browser API. No React tree, no provider. Used by every BV-1-** test.

PlantUML diagram

Configuration B — Single-provider integration
Section titled “Configuration B — Single-provider integration”

A single ItemCardsProvider is rendered through one of the render-with-providers helpers along with the producer or subscriber under test. Each test opts in to the fixtures it needs — fake timers for pollTimer, the visibility helpers for useRefreshOnFocus, the stateful MSW handler when a network round-trip matters, the BroadcastChannel polyfill when channel inspection matters, the stub bus consumer for cycle-safety assertions. Used by BV-2-, BV-3-, BV-4-, BV-5-, BV-6-02, BV-6-03, and BV-7-**.

PlantUML diagram

Configuration C — Cross-tab integration (two-provider harness)
Section titled “Configuration C — Cross-tab integration (two-provider harness)”

The two-provider jsdom harness mounts two independent ItemCardsProvider instances in the same jsdom and binds them to one shared BroadcastChannel polyfill instance — the only way to exercise the cross-tab path without standing up a second browser context. Used by BV-6-01.

PlantUML diagram

Configuration D — E2E (Playwright in mock mode)
Section titled “Configuration D — E2E (Playwright in mock mode)”

Playwright drives a real browser running the app under NEXT_PUBLIC_MOCK_MODE=true. The staleness-mock fixture registers the stateful MSW handler before navigation and overrides NEXT_PUBLIC_ITEM_CARDS_POLL_MS to a short value so the spec doesn’t have to wait two minutes. Used by BV-6-04 and BV-6-05.

PlantUML diagram

Stateful variant of the MSW handler for the cards-for-items endpoint. Returns rId set A on the first call for the fixture eid and set B on subsequent calls. Lets tests simulate “another browser updated the item between two reads” without standing up a second BrowserContext. Used by every test that exercises the cross-process polling path.

A deterministic BroadcastChannel implementation for jsdom. Native BroadcastChannel is not reliably present across jsdom versions; tests use a polyfill (or in-process fake) that supports postMessage, onmessage, and close with synchronous, in-order delivery. Used by every bus mechanics test and by the cross-tab integration test.

Vitest/Jest fake timers that let tests advance the clock past pollTimer’s interval (default 120 s, overridable per-test). Used in any test that needs to assert poll-driven behavior without waiting in real time.

Small DOM utilities that stub document.visibilityState and dispatch visibilitychange / window.focus events. Used by useRefreshOnFocus and pollTimer gating tests.

A vi.fn() / jest.fn() passed as the consumer argument to createItemStaleBus in unit tests. Records every call and its eid set so the test can assert local notify, ordering, and dispose semantics.

Test setup that mounts two independent ItemCardsProvider instances in a single jsdom and wires them to one BroadcastChannel test polyfill instance. Stands in for “two browser tabs” without a real two-context Playwright harness. Used by BV-6-01.

The existing test-utility at src/test-utils/render-with-providers.tsx exports a family of variants — renderWithAll, renderWithRedux, renderWithAuth, renderWithRouter — that render a React tree with the appropriate provider stack (Redux, Auth, ItemCardsProvider, JWT, …) plus MSW handlers. Tests pick the variant that matches the surface under test. Extended for this design with optional overrides on RenderWithProvidersOptions for the pollTimer interval and a Stub bus consumer injection point.

Playwright fixture that boots the app in NEXT_PUBLIC_MOCK_MODE=true and registers the Stateful MSW cards handler before navigation. Used by all E2E tests that verify staleness propagation.


Subsections mirror Key Elements. Each table has one row per (file, construct) pair the implementer will create or modify.

FileConstructContent
src/components/items/ItemFormPanel.tsxItemFormPanel (component)In the post-createItem and post-updateItem success path (the onSuccess branch around the resultItem assignment), call markItemStale(resultItem.entityId) before invoking the caller’s onSuccess callback.
src/app/items/hooks/useDeleteItems.tsuseDeleteItems (hook)In the success branch of the bulk-delete flow, call markItemStale(deletedEntityIds) after the existing toast / dismiss logic.
FileConstructContent
src/components/common/CardStateDropdown.tsxCardStateDropdown (component)On successful card-state event, call markItemStale(itemEntityId) for the row’s item.
src/components/table/columnPresets.tsxQuick-action handlers (cell renderers)On successful card-state event from a grid quick-action, call markItemStale(itemEntityId).
src/components/scan/ScanModal.tsxScanModal (component)On successful scan-driven event, call markItemStale(itemEntityId).
src/components/scan/CardPreviewModal.tsxCardPreviewModal (component)On successful card-state event from the preview modal, call markItemStale(itemEntityId).
src/components/scan/MobileScanView.tsxMobileScanView (component)On successful card-state event in the mobile scan path, call markItemStale(itemEntityId).
src/components/scan/DesktopScanView.tsxDesktopScanView (component)On successful card-state event in the desktop scan path, call markItemStale(itemEntityId).
src/app/order-queue/page.tsxorder-queue page (component)On successful card-state event from the order-queue actions, call markItemStale(itemEntityId).
src/app/kanban/cards/[cardId]/page.tsxdirect kanban-card page (component)On successful card-state event from this page, call markItemStale(itemEntityId).
src/components/orders/OrderSidebar.tsxOrderSidebar (component)On successful card-state event from sidebar actions, call markItemStale(itemEntityId).
FileConstructContent
src/components/items/ItemDetailsPanel.tsxRefreshButton (new JSX in the banner header)New button rendered inside the <StaleDataBanner> block (around line 926). On click, call markItemStale(item.eid).
FileConstructContent
src/app/items/hooks/useRefreshOnFocus.tsuseRefreshOnFocus (hook)Replace the existing direct refreshCardsForItems(eids) call with markItemStale(eids). The eid set is unchanged (displayed-grid-items ∪ open-panel-item).
src/app/items/hooks/useRefreshOnFocus.test.tsxexisting testUpdate assertions from “calls refreshCardsForItems” to “calls markItemStale”. Keep all other test setup.
FileConstructContent
src/app/items/ItemCardsContext.tsxpollTimer (useEffect inside ItemCardsProvider)New effect installs setInterval with period from NEXT_PUBLIC_ITEM_CARDS_POLL_MS (default 120 000 ms). On each tick, short-circuits unless document.visibilityState === "visible" and mountedEids is non-empty; otherwise calls markItemStale(Array.from(mountedEids)). Cleans up on unmount.
FileConstructContent
src/app/items/itemStaleBus.tsItemStaleBus (type)Exported shape: { markItemStale(eid: string | readonly string[]): void; dispose(): void }.
src/app/items/itemStaleBus.tscreateItemStaleBus (factory)createItemStaleBus(consumer: (eids: string[]) => void): ItemStaleBus. Constructs the BroadcastChannel('arda-item-stale') instance, wires onmessage to consumer, returns the bus.
src/app/items/itemStaleBus.tsmarkItemStale (method on bus)Normalizes input to string[], calls consumer(eids) synchronously, then channel.postMessage({type: "item-stale", eids}). Falls back to in-tab-only when BroadcastChannel is undefined.
src/app/items/itemStaleBus.tsdispose (method on bus)Closes the channel and detaches the listener.
src/app/items/__tests__/itemStaleBus.test.tsunit test file (new)Cases: local notify path; cross-tab post path; dispose stops delivery; in-tab-only fallback when BroadcastChannel is undefined; input-normalization (single string vs array).
FileConstructContent
src/app/items/ItemCardsContext.tsxmountedEids (useRef inside ItemCardsProvider)useRef<Set<string>>(new Set()) plus stable register(eid) / unregister(eid) callbacks exposed via the existing internal context. Empty-set short-circuit elsewhere relies on this being a Set.
src/app/items/ItemCardsContext.tsxchannelSubscriber (useEffect)Effect that subscribes to the bus’s channel; onmessage invokes enqueueStaleRefresh(eids) for each eid in the payload. Cleans up on unmount.
src/app/items/ItemCardsContext.tsxmarkItemStale (context method)New method exposed on the public context value. Delegates to the owned itemStaleBus.markItemStale.
src/app/items/ItemCardsContext.tsxItemCardsProvider (component wiring)On mount, call createItemStaleBus(consumer = (eids) => enqueueStaleRefresh(eids)) and store on a ref; on unmount, call dispose(). Add markItemStale to the memoized context value alongside the existing methods.
src/app/items/ItemCardsContext.test.tsxexisting testsAdd coverage for the new context method (markItemStale) and the new registry callbacks. Ensure existing freshness / rId-diff tests remain green.
FileConstructContent
src/app/items/ItemCardsContext.tsxuseFreshRead (hook)In the effect that runs on eid change, call register(eid) and return () => unregister(eid) from the cleanup. No change to freshness / rId-diff logic.
src/app/items/ItemCardsContext.tsxuseStaleCheck (hook)Same registration discipline as useFreshRead: register(eid) on mount / eid change, unregister(eid) on cleanup.
src/app/items/ItemCardsContext.freshRead.test.tsxexisting testAdd a case asserting that mounting / unmounting useFreshRead adjusts mountedEids accordingly.
src/app/items/ItemCardsContext.staleCheck.test.tsxexisting testAdd an analogous case for useStaleCheck.
FileConstructContent
src/components/common/StaleDataBanner.tsxStaleDataBanner (component)No change.

Test-only artifacts (not part of the runtime architecture). Functional descriptions live in Testing Elements. Two Testing Elements — Fake timers and Stub bus consumer — have no implementation artifact: they are vitest/jest built-ins (jest.useFakeTimers() / jest.fn()) used directly in test files.

FileConstructContent
src/mocks/handlers/kanban-queries.tsnew exported stateful handler factoryAdd a stateful variant alongside the existing handlers for the cards-for-items endpoint. Returns rId set A on first call for the fixture eid, set B on subsequent calls. Exported so the integration test can opt in.
FileConstructContent
src/test-utils/broadcastChannelTestEnv.tsinstallBroadcastChannelTestEnv() (new helper)Installs a deterministic BroadcastChannel implementation on globalThis for the duration of a test (or test suite). Synchronous, in-order delivery across instances; idempotent install; teardown helper restores the prior global. Used in test setup.
FileConstructContent
src/test-utils/visibilityTestEvents.tssetVisibility(state), dispatchVisibilityChange(), dispatchWindowFocus() (new helpers)Centralize the Object.defineProperty(document, 'visibilityState', …) + document.dispatchEvent(new Event('visibilitychange')) pattern currently inlined in useRefreshOnFocus.test.tsx and order-queue/page.test.tsx. Reused by all Testing-Elements-5 / -2 tests.
FileConstructContent
src/test-utils/itemCardsTwoProviderHarness.tsxrenderTwoProviderHarness(...) (new helper)Mounts two independent ItemCardsProvider instances in a single jsdom and wires them to one BroadcastChannel test polyfill channel. Returns refs to both providers and helpers to render a child inside either. Used by BV-6-01.
FileConstructContent
src/test-utils/render-with-providers.tsxRenderWithProvidersOptions (interface, modified)Extend the existing options with two optional overrides: itemCardsPollIntervalMs?: number to control pollTimer’s interval per-test, and itemStaleBusConsumer?: (eids: string[]) => void to inject a Stub bus consumer for assertion-spy patterns.
src/test-utils/render-with-providers.tsxrenderWithAll, renderWithRedux, renderWithAuth, renderWithRouter (existing functions)Thread the new options through to the rendered ItemCardsProvider. No change to existing call sites that don’t pass the new options.
FileConstructContent
e2e/fixtures/staleness-mock.tsnew Playwright fixtureExtends e2e/fixtures/base.ts with a setup step that registers the Stateful MSW cards handler for a fixture eid before navigation, and an env override for NEXT_PUBLIC_ITEM_CARDS_POLL_MS so E2E tests don’t wait 120 s.
  • Any change to the operations repository or any backend endpoint behavior, per DQ-010.
  • ETag / If-None-Match conditional GETs (option 5), per DQ-004. Revisit if request volume becomes a problem.
  • Server-sent events / WebSocket / domain-event bus (options 6, 7, 8). Future producers can plug into markItemStale without changes to consumers.
  • Cross-tenant or cross-user authorization for the BroadcastChannel. Channel scope is the browsing context’s origin, which already matches the app’s tenant boundary; no additional check needed.
  • UI redesign of StaleDataBanner or its placement (still ItemDetailsPanel only for cross-user staleness per DQ-006).
  • Reworking useBulkSelectionStaleGuard or the page-level bulk-selection banner. That is a separate staleness mechanism (rId-check pre-flight for bulk delete) and is left untouched.
  • Removing the existing refreshItemCards window-event listener in ItemDetailsPanel. The bus supersedes it semantically but removal is a follow-up cleanup; coexistence is harmless.
  • A real two-browser end-to-end test harness, per DQ-009.
  • Resolution of PDEV-613 (bulk-print trailing empty object) — tracked separately under pdev-613/.

Question. What transport carries a “this item changed” signal from a writer to a concurrent observer?

Chosen. Local invalidation bus (BroadcastChannel + in-tab notify) combined with a provider-level interval poll.

Rationale.

  • The bus closes the same-browser case (multi-tab single user, post-save freshness in the writer’s own tab) at near-zero latency and zero network cost.
  • The interval poll closes the genuinely-cross-process case (different browser, different user) within a bounded SLA.
  • The two layers are additive: the bus’s local-action triggers eliminate the perceived-latency cases, allowing the poll interval to be conservative (DQ-002) without harming UX.
  • The bus is the seam for any future transport (SSE, WS, pub-sub gateway): a new transport plugs in as another producer that calls markItemStale.

Rejected alternatives.

  • RefreshButton only. Doesn’t fix the bug; the user has to know to click it.
  • Focus / visibility revalidation alone. Doesn’t help active multi-screen viewing.
  • BroadcastChannel alone. The bug repro is two users in separate browsers — BroadcastChannel does not cross browser instances.
  • Active polling alone. Workable, but the interval has to be aggressive to feel responsive in the writer’s own tabs, which dominates request volume unnecessarily.
  • SSE from operations (option 6). The right answer if multiple surfaces will need real-time invalidation; deferred. Requires backend work (subscriptions, fan-out, ALB / EKS idle-timeout review) that is out of scope for this ticket, and the bus shape lets us add it later without churning consumers.
  • WebSocket (option 7). Strictly more infrastructure than SSE for a unidirectional use case.
  • Cross-service pub/sub fabric (option 8). Platform decision masquerading as a bug fix; not justified by a single ticket.

Question. What interval should the provider-level poll use?

Chosen. 120 s default, env-tunable via NEXT_PUBLIC_ITEM_CARDS_POLL_MS (mirrors the existing NEXT_PUBLIC_ITEM_CARDS_TTL_MS convention).

Rationale. Customers are SMB manufacturers with low per-tenant concurrent traffic. 120 s is conservative — 180 s is also acceptable. Local action triggers and focus / visibility events catch the “I just did something” cases at near-zero latency, so the poll is only responsible for unrelated, asynchronous edits from other users. A 2-minute upper bound on noticing those is comfortable. Env-tunable so prod can change the value without a redeploy.

Question. When the poll fires, which eids does it refresh?

Chosen. Currently-mounted eids (those with an active useFreshRead / useStaleCheck subscriber). Not the full LRU cache.

Rationale. The banner only matters where the user is looking. The LRU cap is 500 entries; polling all of them every cycle is wasteful and would dominate request volume. The mounted set is small (typically one detail panel + however many grid cells are visible). The provider maintains an explicit registry so this set is cheap to enumerate.

Question. Should the poll use If-None-Match / If-Modified-Since to cut payload cost on no-change responses?

Chosen. Deferred. The backend does not currently return cache headers for cardsForItems. Adding them is an operations change, which DQ-010 excludes.

Rationale. At 120 s intervals and SMB traffic, the saving from conditional GETs is modest. If request volume ever becomes a concern, the change is a clean follow-up: add ETag headers in operations, then have the FE pass If-None-Match. The mechanism does not change.

Question. Which client-side handlers should publish to the bus?

Chosen. Every surface that mutates an item or its kanban-card state, plus three internal producers: the refactored useRefreshOnFocus, the new pollTimer, and a RefreshButton added to ItemDetailsPanel.

The concrete list (from reconnaissance against the current codebase):

  • Item mutations: ItemFormPanel save (calls createItem / updateItem directly via ardaClient), useDeleteItems bulk-delete success path.
  • Card-state events: CardStateDropdown, columnPresets quick-action cell renderers, ScanModal, CardPreviewModal, MobileScanView, DesktopScanView, order-queue/page.tsx, kanban/cards/[cardId]/page.tsx, OrderSidebar. Each triggers a kanban-card state event (request, accept, start-processing, fulfill, unmark) and publishes on success.
  • Internal to the items composition: the existing useRefreshOnFocus (refactored to call markItemStale instead of refreshCardsForItems directly) and the new pollTimer effect inside ItemCardsProvider.
  • UI: new RefreshButton in ItemDetailsPanel’s banner header (the only cross-user-staleness banner host).

Rationale. Each producer publishes the specific eids it knows about, not a blanket “refresh everything.” This keeps refresh traffic proportional to actual mutations. The provider’s microtask coalescer absorbs bursts. The producer surface is wider than a clean hook layer because the app’s mutation paths are not uniformly hook-mediated (direct ardaClient calls and per-component event handlers); each producer is the smallest unit that knows the eid set it just affected.

Non-interference notes.

  • useBulkSelectionStaleGuard runs an rId-check pre-flight before bulk delete and renders its own page-level banner via items/page.tsx. It is not a producer on this bus and is left untouched. It coexists with the bus: when other producers mark items stale, the bulk-selection guard’s own preflight still runs independently when the user clicks Delete.
  • ItemDetailsPanel already listens to a refreshItemCards window event for in-process refresh nudges. The bus supersedes it semantically; removing it is a follow-up cleanup, not a prerequisite.

Question. Should the cross-user staleness banner appear in more places (list grid, sidebar, header, other panels)?

Chosen. No. The banner stays in ItemDetailsPanel only.

Rationale. Reconnaissance found exactly one production host that consumes useFreshRead.isStale and renders <StaleDataBanner> for the cross-user case: src/components/items/ItemDetailsPanel.tsx. The only other <StaleDataBanner> usage in production code is in src/app/items/page.tsx, which is wired to useBulkSelectionStaleGuard — a different staleness mechanism (bulk-delete rId-check pre-flight) that is out of scope here. Expanding placement is a UX call independent of the staleness-transport fix.

Question. Does markItemStale in the publishing tab call its own consumer, or only post to the channel?

Chosen. Both. The helper calls enqueueStaleRefresh synchronously and posts to the channel.

Rationale. BroadcastChannel.postMessage does not echo to the sending context. If markItemStale only posted, a save handler in Tab A would update Tab A’s cache (because the mutation hook already does), but other downstream subscribers in Tab A would not be notified. Doing both keeps the producer API uniform — callers do not need to think about tab boundaries.

Question. What prevents a markItemStale → enqueueStaleRefresh → refreshCardsForItems → markItemStale loop?

Chosen. Only write-originating intents publish. Cache-update completions do not publish.

Rationale. The bus carries “an entity changed” signals from a small set of explicit producers (mutation success, scan, manual refresh, focus resume, poll). The cache-update path in refreshCardsForItems is the consumer of those signals; it does not produce. This single discipline prevents amplification by design.

DQ-009 Test strategy for cross-browser case

Section titled “DQ-009 Test strategy for cross-browser case”

Question. How do we verify the cross-browser case in CI?

Chosen. A stateful MSW handler that returns rId set A on the first call and set B on subsequent calls. The polling integration test asserts the banner appears within one poll cycle. No real two-browser harness.

Rationale. A real two-context Playwright test would require coordinated state across BrowserContext boundaries, which the existing E2E suite does not do. The polling path is what carries the cross-browser signal in production; simulating “the backend now returns different data” exercises exactly the same code path that a real two-browser scenario would, at a fraction of the complexity. The BroadcastChannel path is exercised independently in a jsdom integration test with two consumers sharing one channel.

Question. Does this design require changes to operations or any other backend?

Chosen. No.

Rationale. The local invalidation bus is frontend-only by construction. The provider-level poll uses the existing /kanban/cards-for-items endpoint. ETag support (DQ-004) is deferred; if it ever lands, that work touches operations separately.



Copyright: (c) Arda Systems 2025-2026, All rights reserved