Post-mutation refetch can commit SWR-stale bodies (reaction live update flake) #41

Closed
opened 2026-09-04 17:02:47 +00:00 by crueber · 3 comments
Owner

Follow-up from #31 verification. The thread GET uses ccSWR (private, max-age=0, stale-while-revalidate=60). After a mutation, the explicit reload() refetch and the SSE-frame refetch race: Chrome serves the pre-mutation body with status 200 from disk cache (fromDiskCache=true, observed via CDP), and with no ordering guard in web/src/lib/data.js the stale commit can land last, leaving the timeline stale until the next event. Reproduced ~6/10 runs against the local standalone stack (disk 99% full slows cache reads into losing positions). Evidence: CDP net log [201 reactions, 200 cached=true, 200 cached=false] with DOM stuck pre-mutation; deterministic-fresh with Network.setCacheDisabled. Candidate fix (own vehicle, cross-cutting: comments/patches share the path): bypass HTTP cache on mutation-triggered refetches and/or treat SDK 304 as silent keep-current instead of ReposError(304) into the tray.

Follow-up from #31 verification. The thread GET uses ccSWR (private, max-age=0, stale-while-revalidate=60). After a mutation, the explicit reload() refetch and the SSE-frame refetch race: Chrome serves the pre-mutation body with status 200 from disk cache (fromDiskCache=true, observed via CDP), and with no ordering guard in web/src/lib/data.js the stale commit can land last, leaving the timeline stale until the next event. Reproduced ~6/10 runs against the local standalone stack (disk 99% full slows cache reads into losing positions). Evidence: CDP net log [201 reactions, 200 cached=true, 200 cached=false] with DOM stuck pre-mutation; deterministic-fresh with Network.setCacheDisabled. Candidate fix (own vehicle, cross-cutting: comments/patches share the path): bypass HTTP cache on mutation-triggered refetches and/or treat SDK 304 as silent keep-current instead of ReposError(304) into the tray.
Author
Owner

Fix PR: #52 (branch fix/issue-41) — ordering/generation guard + cache-bypass on mutation refetches + SDK 304 as silent keep-current. node suite 217/217 green; no merge (awaiting review).

Fix PR: https://git.packden.us/crueber/walhub/pulls/52 (branch fix/issue-41) — ordering/generation guard + cache-bypass on mutation refetches + SDK 304 as silent keep-current. node suite 217/217 green; no merge (awaiting review).
Author
Owner

Reviewed PR #52 (fix/issue-41) in scratch worktree at 2e15899. No fixes needed — no new branch pushed.

VERIFIED (scratch worktree, removed afterward; main worktree untouched, read-only):

  • node --test web/test/unit/*.test.js: 217/217 pass (incl. new data-guard + sdk-nostore-304 suites). Note: worktree needed a node_modules symlink to the main checkout to run; no installs, no main-tree writes.
  • vite build: OK (103 modules, built in ~1.6s). esbuild SDK bundle: OK (26.8kb).
  • No browser run (node tests + reasoning per instructions); no docker/compose touched.
  • Diff touches exactly 7 files: docs/go/12_web_ui.md, web/sdk/src/{core,errors,index}.js, web/src/lib/data.js, 2 new test files. Zero useData call-site/page changes by construction; no package.json/lockfile changes (no new deps — law 1 holds).

REVIEW FINDINGS (all checked, none blocking):

  1. Generation guard (data.js:79-106): CORRECT. Stale bodies (L89) and stale errors (L96) both dropped via generation !== entry.seq; invalidate (L180-187) unconditionally starts a new generation (old !entry.promise skip removed — the actual fix). No live-lock: generations only increment on start/invalidate, fetch completion never schedules new invalidations, startIfStale still single-flights (L121) so background reads coalesce as before. Minor behavior delta (not a bug): invalidate on an entry with no refetch fn is now a silent no-op, whereas old code refetched Promise.resolve(current value) — old path was a valueless self-copy; fine.
  2. withNoStore nesting (core.js:286-293): EXACT. try/finally restores on sync throw (covered by test); nesting counts correctly. Async-fn caveat (counter disarms at sync return) is real but documented in the jsdoc and harmless here: invalidate wraps refetch() synchronously (data.js:184-185), and _request reads depth synchronously before the first await, so the bypass always arms for the intended path. Fail-open direction (cached read) is the safe one.
  3. 304 sentinel: CORRECT at both dispatch points (_dispatch L370, _textResponse L389 — checked before !res.ok so 304 never throws). Handled in the only cache path that matters: start() L90 (silent keep-current, freshness bumped, no tray). OBSERVATION (non-blocking): two direct-GET call sites outside the data layer would misread a raw sentinel — Notifications.jsx:16 (res.count ?? 0 → badge 0) and :47-49 (res.notifications ?? [] → blank list). Unreachable in practice (SDK never sends If-None-Match/If-Modified-Since, so no server/proxy should answer 304), and mutations are non-GET so never 304. Suggest a one-line defensive guard there in a follow-up if you want belt-and-braces, but not a merge blocker.
  4. prefetchData seam (data.js:153-159): runtime-neutral. Same ensureEntry→touch/evict→start path; only caller is the new test file (grep-confirmed, no runtime imports). start() with keepRefetch=false sets refetch exactly as the old code did.
  5. Normal-operation preservation: useData/startIfStale/TTL/LRU/tray logic byte-identical apart from the guard; useResolved flows through start() so both steps get the guard + 304 handling, and the sentinel never reaches component signals (intercepted before signal set).
  6. Docs (12_web_ui.md): accurate — withNoStore fallback ('plain refetch when no SDK client wired', data.js:185), 304 keep-current semantics, and storm-coalescing note all match the code.

MERGE RECOMMENDATION: ready to merge.

Reviewed PR #52 (fix/issue-41) in scratch worktree at 2e15899. No fixes needed — no new branch pushed. VERIFIED (scratch worktree, removed afterward; main worktree untouched, read-only): - node --test web/test/unit/*.test.js: 217/217 pass (incl. new data-guard + sdk-nostore-304 suites). Note: worktree needed a node_modules symlink to the main checkout to run; no installs, no main-tree writes. - vite build: OK (103 modules, built in ~1.6s). esbuild SDK bundle: OK (26.8kb). - No browser run (node tests + reasoning per instructions); no docker/compose touched. - Diff touches exactly 7 files: docs/go/12_web_ui.md, web/sdk/src/{core,errors,index}.js, web/src/lib/data.js, 2 new test files. Zero useData call-site/page changes by construction; no package.json/lockfile changes (no new deps — law 1 holds). REVIEW FINDINGS (all checked, none blocking): 1. Generation guard (data.js:79-106): CORRECT. Stale bodies (L89) and stale errors (L96) both dropped via generation !== entry.seq; invalidate (L180-187) unconditionally starts a new generation (old !entry.promise skip removed — the actual fix). No live-lock: generations only increment on start/invalidate, fetch completion never schedules new invalidations, startIfStale still single-flights (L121) so background reads coalesce as before. Minor behavior delta (not a bug): invalidate on an entry with no refetch fn is now a silent no-op, whereas old code refetched Promise.resolve(current value) — old path was a valueless self-copy; fine. 2. withNoStore nesting (core.js:286-293): EXACT. try/finally restores on sync throw (covered by test); nesting counts correctly. Async-fn caveat (counter disarms at sync return) is real but documented in the jsdoc and harmless here: invalidate wraps refetch() synchronously (data.js:184-185), and _request reads depth synchronously before the first await, so the bypass always arms for the intended path. Fail-open direction (cached read) is the safe one. 3. 304 sentinel: CORRECT at both dispatch points (_dispatch L370, _textResponse L389 — checked before !res.ok so 304 never throws). Handled in the only cache path that matters: start() L90 (silent keep-current, freshness bumped, no tray). OBSERVATION (non-blocking): two direct-GET call sites outside the data layer would misread a raw sentinel — Notifications.jsx:16 (res.count ?? 0 → badge 0) and :47-49 (res.notifications ?? [] → blank list). Unreachable in practice (SDK never sends If-None-Match/If-Modified-Since, so no server/proxy should answer 304), and mutations are non-GET so never 304. Suggest a one-line defensive guard there in a follow-up if you want belt-and-braces, but not a merge blocker. 4. prefetchData seam (data.js:153-159): runtime-neutral. Same ensureEntry→touch/evict→start path; only caller is the new test file (grep-confirmed, no runtime imports). start() with keepRefetch=false sets refetch exactly as the old code did. 5. Normal-operation preservation: useData/startIfStale/TTL/LRU/tray logic byte-identical apart from the guard; useResolved flows through start() so both steps get the guard + 304 handling, and the sentinel never reaches component signals (intercepted before signal set). 6. Docs (12_web_ui.md): accurate — withNoStore fallback ('plain refetch when no SDK client wired', data.js:185), 304 keep-current semantics, and storm-coalescing note all match the code. MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #52 (review clean; 217/217 node tests), merged. Closing.

Fixed by PR #52 (review clean; 217/217 node tests), merged. Closing.
crueber added this to the v1 milestone 2026-09-10 22:20:44 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#41
No description provided.