Design: PDEV-610 Cross-User Staleness Signal
Design: PDEV-610 Cross-User Staleness Signal
Section titled “Design: PDEV-610 Cross-User Staleness Signal”Overview
Section titled “Overview”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.
Decision Summary
Section titled “Decision Summary”| # | Decision | Chosen Option |
|---|---|---|
| DQ-001 | Transport mechanism for the staleness signal | Local invalidation bus (BroadcastChannel + in-tab notify) + provider-level interval poll |
| DQ-002 | Polling interval | 120 s default, env-tunable via NEXT_PUBLIC_ITEM_CARDS_POLL_MS |
| DQ-003 | What the poll fetches | Currently-mounted eids only (not the full LRU cache) |
| DQ-004 | Conditional GET / ETag support | Deferred. Backend does not provide today; revisit if request volume warrants. |
| DQ-005 | Which handlers publish to the bus | Item mutations, kanban-card state events at every triggering surface, manual RefreshButton, refactored useRefreshOnFocus, new pollTimer. |
| DQ-006 | Banner placement changes | None. Cross-user staleness banner stays in ItemDetailsPanel only; page-level useBulkSelectionStaleGuard banner unchanged. |
| DQ-007 | How markItemStale handles its own tab | Helper always calls enqueueStaleRefresh locally and posts to the channel. BroadcastChannel does not echo to sender. |
| DQ-008 | Cycle prevention | Only write-originating intents publish. Cache-update completions do not publish. |
| DQ-009 | Test strategy for the cross-browser repro | MSW stateful handler (returns rId set A on first call, set B on Nth call). No real two-browser harness. |
| DQ-010 | Backend (operations) involvement | None for this design. |
Full rationale in the inline Decision Log below.
Structural Design
Section titled “Structural Design”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.
Key Elements
Section titled “Key Elements”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.
Producers (multiple files)
Section titled “Producers (multiple files)”Item mutations (modified)
Section titled “Item mutations (modified)”- Role in the diagram: the
Item mutationsproducer box. - Change: on successful item create / update / delete, call
markItemStale(...)with the affected entityIds. Two real entry points exist today:ItemFormPanel’s save flow (callscreateItem/updateItemdirectly) anduseDeleteItems’s bulk-delete success path. - Design decisions: DQ-005.
Card-state events (modified)
Section titled “Card-state events (modified)”- Role in the diagram: the
Card-state eventsproducer 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
RefreshButtonproducer box (hosted byItemDetailsPanel, the only cross-user-staleness banner host). - Change: add
RefreshButtonto the banner header that callsmarkItemStale(eid)on click. No change toStaleDataBanneritself. The page-level bulk-selection banner initems/page.tsxis driven by a separate mechanism (useBulkSelectionStaleGuard) and is out of scope. - Design decisions: DQ-005, DQ-006.
useRefreshOnFocus (refactored)
Section titled “useRefreshOnFocus (refactored)”- 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
refreshCardsForItemsdirectly when the document becomes visible. Refactor it to callmarkItemStale(eids)instead, witheids = 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.
pollTimer (new, inside ItemCardsProvider)
Section titled “pollTimer (new, inside ItemCardsProvider)”- Role in the diagram: the
pollTimer (new)producer box. Lives inside theItemCardsProviderpackage in code but acts as a producer; arrows in the diagram cross the package boundary intomarkItemStale. - Change:
setIntervalinstalled onItemCardsProvidermount, default 120 s, env-tunable viaNEXT_PUBLIC_ITEM_CARDS_POLL_MS. On each tick, ifdocument.visibilityState === "visible"andmountedEidsis non-empty, callsmarkItemStale(Array.from(mountedEids)). Disposed on unmount. - Design decisions: DQ-001, DQ-002, DQ-003.
itemStaleBus (new)
Section titled “itemStaleBus (new)”- Role in the diagram: the
itemStaleBus (new)package. - Responsibility: owns the
BroadcastChannel('arda-item-stale')instance and exposesmarkItemStale. Constructed onItemCardsProvidermount; disposed on unmount. - Public surface:
createItemStaleBus(consumer: (eids: string[]) => void): ItemStaleBus— factory.markItemStale(eid: string | readonly string[]): void— callsconsumer(eids)synchronously and posts{type: "item-stale", eids}on the channel.dispose(): void— closes the channel.
- Design decisions: DQ-001, DQ-007.
ItemCardsProvider (modified)
Section titled “ItemCardsProvider (modified)”- Role in the diagram: the
ItemCardsProviderpackage. HostschannelSubscriber,enqueueStaleRefresh,refreshCardsForItems,itemCardsMap, andmountedEids. Also ownspollTimer(described as a Producer above) and theitemStaleBusinstance. - New members:
mountedEids: Set<string>— registry of eids currently being observed byuseFreshRead/useStaleCheck. Populated byregister(eid)/unregister(eid)calls from those hooks. Read byuseRefreshOnFocusandpollTimerto decide what to revalidate.channelSubscriber—onmessagehandler attached to the bus channel; invokesenqueueStaleRefresh(eids).
- Exposed on context:
markItemStale(delegates to the owneditemStaleBus). - Unchanged:
enqueueStaleRefresh,refreshCardsForItems,itemCardsMap. - Design decisions: DQ-001, DQ-008.
Subscribers (multiple files)
Section titled “Subscribers (multiple files)”useFreshRead, useStaleCheck (modified)
Section titled “useFreshRead, useStaleCheck (modified)”- Role in the diagram: the
useFreshRead\nuseStaleChecksubscriber box. - Change: in their effect, call
register(eid)against the provider’smountedEidson mount andunregister(eid)on cleanup. The freshness / rId-diff logic is unchanged. - Design decisions: DQ-003.
StaleDataBanner (unchanged)
Section titled “StaleDataBanner (unchanged)”- Role in the diagram: the
StaleDataBannersubscriber box. - Change: none. Two production hosts render it today:
ItemDetailsPanel(cross-user staleness, the PDEV-610 surface) anditems/page.tsx(bulk-selection rId-check viauseBulkSelectionStaleGuard, a separate mechanism untouched by this design). - Design decisions: DQ-006.
Behavioral Design
Section titled “Behavioral Design”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.
1. Bus mechanics
Section titled “1. Bus mechanics”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. SeeitemStaleBus. - Cross-tab post.
markItemStale(eids)posts{type: "item-stale", eids}onBroadcastChannel('arda-item-stale'). SeeitemStaleBus. - Self-tab compensation. The publishing tab does not receive its own
postMessage; the helper’s local-notify branch covers this case (per DQ-007). SeeitemStaleBus. - Input normalization.
markItemStale("X")andmarkItemStale(["X","Y"])both reach the consumer asstring[]. SeeitemStaleBus. - Dispose. After
dispose(), neither inbound delivery nor outbound posts occur. SeeitemStaleBus. - 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 viapollTimerstill works.
2. Producer wiring
Section titled “2. Producer wiring”Each producer publishes correctly on the right occasion with the right eids. Owners: the leaf subsections of Producers.
ItemFormPanelsave. OncreateItem/updateItemsuccess →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.columnPresetsquick 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.RefreshButtonclick. On click inItemDetailsPanel’s banner header →markItemStale(item.eid). SeeRefreshButton.useRefreshOnFocuson visible transition. Onvisibilitychange → visible→markItemStale(displayed-grid-items ∪ open-panel-item). SeeuseRefreshOnFocus.pollTimertick. When gating conditions hold (see §5), the tick →markItemStale(Array.from(mountedEids)). SeepollTimer.
3. Mount registry
Section titled “3. Mount registry”The eid set read by focus-resume and the poll must reflect actual subscribers. Owners: ItemCardsProvider and useFreshRead, useStaleCheck.
useFreshReadregistration. Mount → eid present inmountedEids; unmount → eid removed. SeeuseFreshRead,useStaleCheck.useStaleCheckregistration. Same discipline asuseFreshRead. SeeuseFreshRead,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.
4. Consumer wiring
Section titled “4. Consumer wiring”ItemCardsProvider turns bus signals into batched refreshes. Owner: ItemCardsProvider.
channelSubscriber→ enqueue. An inbound channel message invokesenqueueStaleRefresh(eids). SeeItemCardsProvider.- In-tab notify → enqueue. Local
markItemStale(no channel hop) also reachesenqueueStaleRefresh. SeeItemCardsProvider. - Microtask coalescing. Multiple
markItemStalecalls within one JS tick collapse into a single batchedrefreshCardsForItems(eids)call (existing coalescer; this design must not regress it). SeeItemCardsProvider. - Context-exposed
markItemStale. Consumers readinguseItemCards()getmarkItemStalethat delegates to the owned bus. SeeItemCardsProvider.
5. Gating behaviors
Section titled “5. Gating behaviors”Conditions under which producers correctly do nothing — the system stays cheap when nothing useful would happen.
pollTimerskipped while hidden. Whendocument.visibilityState !== "visible", the tick is a no-op. SeepollTimer.pollTimerno-op on empty registry. WhenmountedEidsis empty, the tick is a no-op. Zero background traffic when nothing is open. SeepollTimer.useRefreshOnFocusno-op on empty eid set. When there are no displayed items and no open panel item, the focus handler is a no-op. SeeuseRefreshOnFocus.pollTimerdisposed on unmount. No leaked intervals afterItemCardsProviderunmount. SeepollTimerandItemCardsProvider.
6. End-to-end propagation
Section titled “6. End-to-end propagation”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.
Cross-browser via pollTimer
Section titled “Cross-browser via pollTimer”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).
In-tab writer feedback
Section titled “In-tab writer feedback”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.
7. Robustness and coexistence
Section titled “7. Robustness and coexistence”Failure modes and interaction with adjacent mechanisms.
- Refresh failure does not flicker the banner. When
refreshCardsForItemsresolves to the fetch-failed sentinel (transport / 5xx),useFreshReadleavesisStaleunchanged. 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
itemStaleBusandItemCardsProvider. useBulkSelectionStaleGuardcoexistence. 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).refreshItemCardswindow-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 inItemCardsProviderabsorbs the burst.
Behavior Verification
Section titled “Behavior Verification”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.
1. Bus mechanics — verification
Section titled “1. Bus mechanics — verification”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-1-01 | Local notify — markItemStale calls the consumer synchronously | Instantiate bus with a stub consumer; call markItemStale("X") and markItemStale(["X","Y"]) | Stub bus consumer |
| BV-1-02 | Cross-tab post — markItemStale posts on the channel | Spy on postMessage of a polyfilled BroadcastChannel; call markItemStale | BroadcastChannel test polyfill, Stub bus consumer |
| BV-1-03 | Self-tab compensation — sender does not receive its own post; helper covers via local notify | Two bus instances on one polyfilled channel; assert sender’s consumer fires via local path, receiver’s via the channel | BroadcastChannel test polyfill, Stub bus consumer |
| BV-1-04 | Input normalization — string and string[] both deliver as string[] | Bus + stub consumer; call with each input shape; assert delivered shape | Stub bus consumer |
| BV-1-05 | Dispose — after dispose(), no further inbound delivery or outbound posts | Bus + spy; call dispose(); further markItemStale calls and channel posts are no-ops | BroadcastChannel test polyfill, Stub bus consumer |
| BV-1-06 | Capability fallback — when BroadcastChannel is undefined, in-tab-only delivery without throwing | Override globalThis.BroadcastChannel to undefined for the test | Stub bus consumer |
2. Producer wiring — verification
Section titled “2. Producer wiring — verification”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-2-01 | ItemFormPanel save — createItem / updateItem success → markItemStale(entityId) | Render ItemFormPanel; mock ardaClient.createItem / updateItem to succeed; spy markItemStale on the context | Render-with-providers helpers |
| BV-2-02 | useDeleteItems — success path → markItemStale(deletedEntityIds) | Hook test with mock context; resolve delete promise; assert spy | Render-with-providers helpers |
| BV-2-03 | Card-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-04 | RefreshButton click — click in ItemDetailsPanel banner → markItemStale(item.eid) | Render ItemDetailsPanel with isStale=true; click the button; spy | Render-with-providers helpers |
| BV-2-05 | useRefreshOnFocus — on visible transition, calls markItemStale(displayed ∪ open-panel-item) | Mount the hook with a stub grid ref + open-panel item; dispatch visibilitychange → visible; spy | Render-with-providers helpers, Visibility / focus event helpers |
| BV-2-06 | pollTimer tick — under gating, the tick calls markItemStale(Array.from(mountedEids)) | Mount provider with one subscribed eid; advance fake timer past the interval; spy | Render-with-providers helpers, Fake timers |
3. Mount registry — verification
Section titled “3. Mount registry — verification”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-3-01 | useFreshRead / useStaleCheck register/unregister — mount adds eid, unmount removes it | Render either hook; assert mountedEids via a test-only accessor on the provider; unmount | Render-with-providers helpers |
| BV-3-02 | eid change on a live subscriber — old eid removed, new eid added | Render with eid X; rerender with eid Y; assert registry | Render-with-providers helpers |
| BV-3-03 | Multiple 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 last | Render-with-providers helpers |
4. Consumer wiring — verification
Section titled “4. Consumer wiring — verification”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-4-01 | channelSubscriber → enqueue — inbound channel message invokes enqueueStaleRefresh(eids) | Mount provider; post on the polyfilled channel; spy enqueueStaleRefresh | BroadcastChannel test polyfill, Render-with-providers helpers |
| BV-4-02 | In-tab notify → enqueue — context markItemStale reaches enqueueStaleRefresh | Mount provider; call the exposed markItemStale; spy | Render-with-providers helpers |
| BV-4-03 | Microtask coalescing — many markItemStale calls in one tick → single batched refreshCardsForItems | Spy refreshCardsForItems; fire several calls synchronously; assert a single batched call after the microtask | Render-with-providers helpers |
| BV-4-04 | Context-exposed markItemStale — useItemCards() returns a function that delegates to the bus | Render; call via context; assert bus’s markItemStale was invoked | Render-with-providers helpers, Stub bus consumer |
5. Gating behaviors — verification
Section titled “5. Gating behaviors — verification”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-5-01 | pollTimer skipped while hidden — visibilityState !== "visible" → no-op | Stub document.visibilityState to "hidden"; advance fake timer; assert no call | Fake timers, Visibility / focus event helpers |
| BV-5-02 | pollTimer no-op on empty registry — empty mountedEids → no-op | Provider with no subscribers; advance fake timer; assert no call | Fake timers, Render-with-providers helpers |
| BV-5-03 | useRefreshOnFocus no-op on empty eid set | Hook with empty grid + no open-panel item; dispatch visibility; assert no call | Visibility / focus event helpers, Render-with-providers helpers |
| BV-5-04 | pollTimer disposed on unmount — no leaked intervals | Mount provider; unmount; advance fake timer; assert no call | Fake timers, Render-with-providers helpers |
6. End-to-end propagation — verification
Section titled “6. End-to-end propagation — verification”Integration
Section titled “Integration”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-6-01 | Same-browser cross-tab via BroadcastChannel — write in one provider triggers banner in a sibling | Two ItemCardsProvider instances in one jsdom sharing the polyfilled channel; render ItemDetailsPanel on both; mutate via the first | Two-provider jsdom harness, BroadcastChannel test polyfill, Render-with-providers helpers |
| BV-6-02 | Cross-browser via pollTimer — banner appears within one poll cycle without user action | Provider with short poll interval override; stateful MSW handler returning S0 then S1; render ItemDetailsPanel; advance fake timer | Stateful MSW cards handler, Fake timers, Render-with-providers helpers |
| BV-6-03 | In-tab writer feedback — writer’s own UI gets the banner after their mutation | Single provider; render ItemDetailsPanel; trigger a mutation whose follow-up refresh returns a different rId set | Stateful MSW cards handler, Render-with-providers helpers |
| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-6-04 | Cross-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 banner | Playwright stateful-mock setup |
| BV-6-05 | RefreshButton 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 clears | Playwright 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
rIdset has changed between two reads.
7. Robustness and coexistence — verification
Section titled “7. Robustness and coexistence — verification”Unit / Integration
Section titled “Unit / Integration”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-7-01 | Refresh failure does not flicker the banner — isStale unchanged when refresh resolves to the fetch-failed sentinel | Mock refreshCardsForItems to resolve to the sentinel; mount useFreshRead; assert isStale stays false | Render-with-providers helpers |
| BV-7-02 | Cycle safety — cache-update completions do not publish to the bus | Spy markItemStale; trigger an inbound channel message; assert no further markItemStale is invoked after the refresh resolves | Render-with-providers helpers, Stub bus consumer |
| BV-7-03 | useBulkSelectionStaleGuard non-interference — bulk delete still works under concurrent bus traffic | Existing useBulkSelectionStaleGuard tests remain green; add one case where markItemStale is invoked concurrently with the guard | Render-with-providers helpers |
| BV-7-04 | refreshItemCards 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 eid | Render-with-providers helpers, Visibility / focus event helpers |
Testing Elements
Section titled “Testing Elements”Functional descriptions of every test fixture referenced above. File-level wiring lives in Testing Artifacts under Implementation Artifacts.
Test Configurations
Section titled “Test Configurations”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.
Configuration A — Bus mechanics (unit)
Section titled “Configuration A — Bus mechanics (unit)”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.
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-**.
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.
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.
Stateful MSW cards handler
Section titled “Stateful MSW cards handler”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.
BroadcastChannel test polyfill
Section titled “BroadcastChannel test polyfill”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.
Fake timers
Section titled “Fake timers”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.
Visibility / focus event helpers
Section titled “Visibility / focus event helpers”Small DOM utilities that stub document.visibilityState and dispatch visibilitychange / window.focus events. Used by useRefreshOnFocus and pollTimer gating tests.
Stub bus consumer
Section titled “Stub bus consumer”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.
Two-provider jsdom harness
Section titled “Two-provider jsdom harness”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.
Render-with-providers helpers
Section titled “Render-with-providers helpers”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 stateful-mock setup
Section titled “Playwright stateful-mock setup”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.
Implementation Artifacts
Section titled “Implementation Artifacts”Subsections mirror Key Elements. Each table has one row per (file, construct) pair the implementer will create or modify.
Producers
Section titled “Producers”Item mutations (modified)
Section titled “Item mutations (modified)”| File | Construct | Content |
|---|---|---|
src/components/items/ItemFormPanel.tsx | ItemFormPanel (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.ts | useDeleteItems (hook) | In the success branch of the bulk-delete flow, call markItemStale(deletedEntityIds) after the existing toast / dismiss logic. |
Card-state events (modified)
Section titled “Card-state events (modified)”| File | Construct | Content |
|---|---|---|
src/components/common/CardStateDropdown.tsx | CardStateDropdown (component) | On successful card-state event, call markItemStale(itemEntityId) for the row’s item. |
src/components/table/columnPresets.tsx | Quick-action handlers (cell renderers) | On successful card-state event from a grid quick-action, call markItemStale(itemEntityId). |
src/components/scan/ScanModal.tsx | ScanModal (component) | On successful scan-driven event, call markItemStale(itemEntityId). |
src/components/scan/CardPreviewModal.tsx | CardPreviewModal (component) | On successful card-state event from the preview modal, call markItemStale(itemEntityId). |
src/components/scan/MobileScanView.tsx | MobileScanView (component) | On successful card-state event in the mobile scan path, call markItemStale(itemEntityId). |
src/components/scan/DesktopScanView.tsx | DesktopScanView (component) | On successful card-state event in the desktop scan path, call markItemStale(itemEntityId). |
src/app/order-queue/page.tsx | order-queue page (component) | On successful card-state event from the order-queue actions, call markItemStale(itemEntityId). |
src/app/kanban/cards/[cardId]/page.tsx | direct kanban-card page (component) | On successful card-state event from this page, call markItemStale(itemEntityId). |
src/components/orders/OrderSidebar.tsx | OrderSidebar (component) | On successful card-state event from sidebar actions, call markItemStale(itemEntityId). |
RefreshButton (new behavior)
Section titled “RefreshButton (new behavior)”| File | Construct | Content |
|---|---|---|
src/components/items/ItemDetailsPanel.tsx | RefreshButton (new JSX in the banner header) | New button rendered inside the <StaleDataBanner> block (around line 926). On click, call markItemStale(item.eid). |
useRefreshOnFocus (refactored)
Section titled “useRefreshOnFocus (refactored)”| File | Construct | Content |
|---|---|---|
src/app/items/hooks/useRefreshOnFocus.ts | useRefreshOnFocus (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.tsx | existing test | Update assertions from “calls refreshCardsForItems” to “calls markItemStale”. Keep all other test setup. |
pollTimer (new)
Section titled “pollTimer (new)”| File | Construct | Content |
|---|---|---|
src/app/items/ItemCardsContext.tsx | pollTimer (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. |
itemStaleBus (new)
Section titled “itemStaleBus (new)”| File | Construct | Content |
|---|---|---|
src/app/items/itemStaleBus.ts | ItemStaleBus (type) | Exported shape: { markItemStale(eid: string | readonly string[]): void; dispose(): void }. |
src/app/items/itemStaleBus.ts | createItemStaleBus (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.ts | markItemStale (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.ts | dispose (method on bus) | Closes the channel and detaches the listener. |
src/app/items/__tests__/itemStaleBus.test.ts | unit 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). |
ItemCardsProvider (modified)
Section titled “ItemCardsProvider (modified)”| File | Construct | Content |
|---|---|---|
src/app/items/ItemCardsContext.tsx | mountedEids (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.tsx | channelSubscriber (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.tsx | markItemStale (context method) | New method exposed on the public context value. Delegates to the owned itemStaleBus.markItemStale. |
src/app/items/ItemCardsContext.tsx | ItemCardsProvider (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.tsx | existing tests | Add coverage for the new context method (markItemStale) and the new registry callbacks. Ensure existing freshness / rId-diff tests remain green. |
Subscribers
Section titled “Subscribers”useFreshRead, useStaleCheck (modified)
Section titled “useFreshRead, useStaleCheck (modified)”| File | Construct | Content |
|---|---|---|
src/app/items/ItemCardsContext.tsx | useFreshRead (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.tsx | useStaleCheck (hook) | Same registration discipline as useFreshRead: register(eid) on mount / eid change, unregister(eid) on cleanup. |
src/app/items/ItemCardsContext.freshRead.test.tsx | existing test | Add a case asserting that mounting / unmounting useFreshRead adjusts mountedEids accordingly. |
src/app/items/ItemCardsContext.staleCheck.test.tsx | existing test | Add an analogous case for useStaleCheck. |
StaleDataBanner (unchanged)
Section titled “StaleDataBanner (unchanged)”| File | Construct | Content |
|---|---|---|
src/components/common/StaleDataBanner.tsx | StaleDataBanner (component) | No change. |
Testing Artifacts
Section titled “Testing Artifacts”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.
Stateful MSW cards handler (new)
Section titled “Stateful MSW cards handler (new)”| File | Construct | Content |
|---|---|---|
src/mocks/handlers/kanban-queries.ts | new exported stateful handler factory | Add 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. |
BroadcastChannel test polyfill (new)
Section titled “BroadcastChannel test polyfill (new)”| File | Construct | Content |
|---|---|---|
src/test-utils/broadcastChannelTestEnv.ts | installBroadcastChannelTestEnv() (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. |
Visibility / focus event helpers (new)
Section titled “Visibility / focus event helpers (new)”| File | Construct | Content |
|---|---|---|
src/test-utils/visibilityTestEvents.ts | setVisibility(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. |
Two-provider jsdom harness (new)
Section titled “Two-provider jsdom harness (new)”| File | Construct | Content |
|---|---|---|
src/test-utils/itemCardsTwoProviderHarness.tsx | renderTwoProviderHarness(...) (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. |
Render-with-providers helpers (modified)
Section titled “Render-with-providers helpers (modified)”| File | Construct | Content |
|---|---|---|
src/test-utils/render-with-providers.tsx | RenderWithProvidersOptions (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.tsx | renderWithAll, 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. |
Playwright stateful-mock setup (new)
Section titled “Playwright stateful-mock setup (new)”| File | Construct | Content |
|---|---|---|
e2e/fixtures/staleness-mock.ts | new Playwright fixture | Extends 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. |
Out of Scope
Section titled “Out of Scope”- Any change to the
operationsrepository or any backend endpoint behavior, per DQ-010. - ETag /
If-None-Matchconditional 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
markItemStalewithout 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
StaleDataBanneror its placement (stillItemDetailsPanelonly for cross-user staleness per DQ-006). - Reworking
useBulkSelectionStaleGuardor 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
refreshItemCardswindow-event listener inItemDetailsPanel. 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/.
Decision Log
Section titled “Decision Log”DQ-001 Transport mechanism
Section titled “DQ-001 Transport mechanism”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.
RefreshButtononly. 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.
DQ-002 Polling interval
Section titled “DQ-002 Polling interval”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.
DQ-003 Polling scope
Section titled “DQ-003 Polling scope”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.
DQ-004 Conditional GET / ETag
Section titled “DQ-004 Conditional GET / ETag”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.
DQ-005 Producer set
Section titled “DQ-005 Producer set”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:
ItemFormPanelsave (callscreateItem/updateItemdirectly viaardaClient),useDeleteItemsbulk-delete success path. - Card-state events:
CardStateDropdown,columnPresetsquick-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 callmarkItemStaleinstead ofrefreshCardsForItemsdirectly) and the newpollTimereffect insideItemCardsProvider. - UI: new
RefreshButtoninItemDetailsPanel’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.
useBulkSelectionStaleGuardruns an rId-check pre-flight before bulk delete and renders its own page-level banner viaitems/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.ItemDetailsPanelalready listens to arefreshItemCardswindow event for in-process refresh nudges. The bus supersedes it semantically; removing it is a follow-up cleanup, not a prerequisite.
DQ-006 Banner placement
Section titled “DQ-006 Banner placement”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.
DQ-007 Self-tab delivery semantics
Section titled “DQ-007 Self-tab delivery semantics”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.
DQ-008 Cycle prevention
Section titled “DQ-008 Cycle prevention”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.
DQ-010 Backend involvement
Section titled “DQ-010 Backend involvement”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.
References
Section titled “References”- PDEV-610 — Linear ticket
- PDEV-596 — Parent manual regression
pdev-610/goal.md— Project goal and constraints.pdev-610/investigation.md— Working notebook with the full options analysis.arda-frontend-app/src/app/items/ItemCardsContext.tsx— primary modification site.arda-frontend-app/src/components/common/StaleDataBanner.tsx— banner component (unchanged).arda-frontend-app/src/app/items/itemCardsMapLru.ts— LRU cache (cap 500).
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved