Skip to content

fix(useDoc): don't self-evict the doc ref on first uncached load#800

Merged
netchampfaris merged 1 commit into
mainfrom
fix/docstore-getdoc-self-eviction
Jun 26, 2026
Merged

fix(useDoc): don't self-evict the doc ref on first uncached load#800
netchampfaris merged 1 commit into
mainfrom
fix/docstore-getdoc-self-eviction

Conversation

@netchampfaris

@netchampfaris netchampfaris commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Problem

The first uncached load of any document via useDoc crashes and renders blank:

Failed to set doc in IDB: TypeError: Cannot read properties of undefined (reading 'value')
  at useDoc.ts   → let value = storeRef.value
  at docStore.ts → docRef.value = doc   (setDoc)

Root cause — two defects, both from getDoc returning the in-memory ref synchronously

getDoc returns the ref synchronously and its caller (useDoc's doc computed) dereferences it immediately. Two separate paths violate that contract:

1. setDoc ordering → first-uncached-load crash + IDB churn

setDoc set lastFetched after docRef.value = doc. Assigning the ref synchronously re-runs the doc computed, which re-enters getDoc; the entry still looked stale (lastFetched unset), so getDoc kicked off a reload that — via cleanup() — deleted the very ref it was about to return. getDoc then returned undefined and the computed crashed on .value. loadDoc(firstLoad=true) never sets lastFetched when IDB is empty, so this fired on every first uncached load.

2. loadDoc stale path → stale-access crash (independent of #1)

The stale-reload branch called cleanup(), whose first synchronous line is this.docs.delete(key). So any doc accessed after it goes stale (a long-lived useDoc re-evaluated past the cache timeout) makes getDoc's return this.docs.get(key)! yield undefined and crash — the first-uncached-load was just the most common trigger.

Fix

  1. setDoc marks lastFetched before assigning the ref, so the synchronous re-run sees a fresh entry and never reloads — no crash, and the just-written IDB copy isn't evicted by a needless reload.
  2. The loadDoc stale path evicts the IDB copy and clears the timestamp but keeps the in-memory ref. cleanup() is untouched and still used by invalidateDoc/removeDoc, which intend full removal.

Tests

Adds src/data-fetching/docStore.test.ts (none existed):

  • first uncached load — a synchronously-tracked reader (mirroring useDoc's computed via watchSyncEffect) through setDoc; asserts no crash and that the IDB copy survives.
  • stale access — forces the entry past the cache timeout; asserts getDoc never returns undefined.

Both fail on the prior code and pass with this change. Full src/data-fetching/ suite (29 tests) passes.

Reproduced and verified in a downstream app (Gameplan): every first discussion open rendered blank with the error above; with this change the doc loads on first visit, the console is clean, and no unhandled rejection fires.

https://claude.ai/code/session_01CG9r6WdYSLUhcTKbNXcoki

Coverage: 57.93% (±0.00% vs main)

@netchampfaris netchampfaris added the beta-release Auto-publish a beta to npm when the PR is merged label Jun 26, 2026
@barista-for-frappe

Copy link
Copy Markdown

Concerns — fix stops the crash, but leaves a side-effect worth a look.

  • Good diagnosis and the comment at src/data-fetching/docStore.ts:80-88 is clear. The crash (deleting the in-memory ref that getDoc returns synchronously) is real and this avoids it.
  • Likely side-effect: the spurious reload still fires. During setDoc, docRef.value = doc (docStore.ts:43) re-runs the computed → getDoclastFetched isn't set yet (line 45 runs after) → isStale true → loadDoc(key, false). With the fix that branch now runs await idbStore.delete(...) (:90) — which deletes the doc setDoc just wrote to IDB at :37. The in-memory ref survives so the component renders, but the IDB cache for that doc is wiped until the next fetch. So first load works but doesn't get persistently cached.
  • Cleaner alternative: in setDoc, set lastFetched before docRef.value = doc (swap :43 and :45). Then the synchronous re-run sees the entry as fresh, isStale is false, and loadDoc(false) never fires — no crash, no IDB churn. Worth checking whether that's simpler than the eviction split.
  • No test added. This is a subtle reactivity race that took a downstream app to surface; a regression test in useDoc.test.ts (or a new docStore.test.ts) covering first-uncached-load would lock it in. There's no docStore test today.

…cStore

The first uncached load of any doc via useDoc crashed and rendered blank:

  Failed to set doc in IDB: TypeError: Cannot read properties of
  undefined (reading 'value')  (useDoc.ts → storeRef.value)

Two distinct defects, both rooted in getDoc returning the in-memory ref
synchronously while its caller (useDoc's `doc` computed) dereferences it
immediately:

1. setDoc set lastFetched AFTER assigning docRef.value. Assigning the ref
   synchronously re-runs the computed, which re-enters getDoc; the entry
   still looked stale (lastFetched unset), so getDoc kicked off a reload
   that, via cleanup(), deleted the very ref it was about to return —
   getDoc then returned undefined and the computed crashed on `.value`.
   This fired on every first uncached load. Fix: mark lastFetched fresh
   before assigning the ref, so the synchronous re-run sees a fresh entry
   and never reloads — no crash, and no eviction of the just-written IDB
   copy (which the reload's idbStore.delete would otherwise wipe).

2. Independently, the stale-reload branch in loadDoc called cleanup(),
   whose first synchronous line deletes the map entry. Any doc accessed
   after it goes stale (a long-lived useDoc re-evaluated past the cache
   timeout) would make getDoc return undefined and crash. Fix: the stale
   path now evicts the IDB copy and clears the timestamp but keeps the
   in-memory ref. cleanup() stays for the explicit invalidateDoc/removeDoc
   paths, which intend full removal.

Adds docStore.test.ts covering both: a synchronously-tracked reader
through a first uncached load, and a stale access. Both fail on the
prior code and pass here.

Claude-Session: https://claude.ai/code/session_01CG9r6WdYSLUhcTKbNXcoki
@netchampfaris netchampfaris force-pushed the fix/docstore-getdoc-self-eviction branch from 29c1e14 to f5be824 Compare June 26, 2026 06:07
@netchampfaris

Copy link
Copy Markdown
Contributor Author

Updated from review feedback:

  • Killed the IDB churn at its source. The original eviction-split stopped the crash but left a spurious stale-reload during setDoc that wiped the just-written IDB copy. Reordering setDoc to mark lastFetched before assigning the ref removes that reload entirely (the synchronous re-run now sees a fresh entry), so the cache persists on first load.
  • Kept the loadDoc ref-preservation too — it fixes a second, independent crash the reorder doesn't reach: any doc accessed after it legitimately goes stale would still hit cleanup()'s synchronous docs.delete and make getDoc return undefined.
  • Added docStore.test.ts (there was no docStore test) covering both the first-uncached-load and stale-access paths. Both fail on the prior code and pass now.

@netchampfaris netchampfaris merged commit 17eee29 into main Jun 26, 2026
@netchampfaris netchampfaris deleted the fix/docstore-getdoc-self-eviction branch June 26, 2026 06:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

beta-release Auto-publish a beta to npm when the PR is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant