Skip to content

fix(hooks): a reader's own first build after mount doesn't run it again - #43

Merged
jarrednorrisdev merged 5 commits into
mainfrom
fix/reader-own-build
Oct 10, 2026
Merged

jarrednorrisdev merged 5 commits into
mainfrom
fix/reader-own-build

Conversation

@jarrednorrisdev

@jarrednorrisdev jarrednorrisdev commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

What broke

subscribedReader drops the announcement a reader's own registry.get makes when it builds an atom for the first time, since that announcement carries the very value the read returns. It did this only before mount (if (mounted || reading !== current), from JND-95). After mount the announcement still reached the reader and re-ran it with an unchanged value. That contradicts the useAtomValue JSDoc: "The transform runs again only when the atom, or state the transform reads, changes, so a transform that builds an object returns the same object until then."

Scenarios:

  • useAtomValue(() => family(id), transform) with id switching to an atom not built yet ran the transform twice for it (['a', 'b', 'b']) and returned a new object for an unchanged value.
  • An $effect that reads an atom first built there ran twice ([1, 1]), with or without a transform.
  • useAtomSuspense of an atom that resolves synchronously, awaited in an $effect, ran the code after the await twice.

The after-mount delivery was kept for JND-24's afterMount pattern, which JND-98 later removed, so nothing relies on it now.

Fix

if (reading !== current). After mount, the read sets reading only while it builds a node for the first time (see the follow-ups below). The comment there now explains why this is safe: only the build's own announcement is dropped. A value set during a registry batch is announced at commit, after reading is reset, and later changes arrive as usual.

Step through one click: step 7 is where it goes wrong, and the same step with the fix.

Tests

Red without the fix in Chromium and WebKit, green with it:

  • hooks: a getter switch to an atom not built yet runs the transform once for it; a transform read only in an $effect runs once while the atom is unchanged; an $effect that first builds the atom it reads runs once.
  • async: a synchronously resolving atom awaited in an $effect runs the code after the await once.

Guards, sync and async renders, passing before and after: a stream's sync emissions in the first build then an async one; a first build that sets another atom the component reads; a first build that sets an atom it read and so rebuilds; a first build that calls setSelf at once and later; later changes of the switched-to atom still arriving after a switch.

Library suites (Chromium, WebKit, server) 478/478, devtools 70/70, and format, lint and check are all clean.

Accepted edge cases

  • A build that commits a value and then throws stays errored, where before it recovered on a re-read. Before mount it already behaves this way today.
  • An untracked first read with a non-reactive getter no longer gets an accidental re-render.

Merges

Brought up to date with main on 2026-10-10, after #39 and #42 changed the same file. Git merged it with no conflicts, and CI passed on the result.

Follow-up: a stale node rebuilt by a later read

A review found that dropping every own-read announcement after mount lost one real update. If an atom's rebuild fails, the node stays stale. When a later imperative read (an event handler reading .current) rebuilds it, that announcement carries a value the page hasn't shown. The reader dropped it, so the markup stayed on the old value. After mount, the reader now drops only the announcement of a first build, checked by whether the node is uninitialized before the read. The mounted flag is back for this. The SAFETY comment on the registry internals now names failed as private to Effect's node implementation; currentState is on its public Node interface. Test: "a failed rebuild that a later read recovers reaches the page".

A node whose first build failed also counts: it stays uninitialized, but the page has shown the failure, so the reader only treats a node that hasn't failed as a first build. Test: "a failed first build that a later read recovers reaches the page".

🤖 Generated with Claude Code

jarrednorrisdev and others added 2 commits October 9, 2026 04:52
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After mount the reader now drops only the announcement of a first build. A stale
node its read rebuilds, as after a failed rebuild, carries a value its other reads
haven't seen, so that announcement is delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 9, 2026
Its finalizer error escapes the provider's teardown and aborts the Svelte
flush that unmounts it, leaving Svelte's scheduler broken for the rest of
the page. Later tests in hooks.browser.test.ts then saw each $derived run
again on every read, which failed #43's transform-once test when the
branches were merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he page

A node whose first build throws stays uninitialized, so the reader took its
recovery for a first build and dropped it, though the page showed the failure.
Only a node that hasn't failed counts as a first build now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 10, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 10, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 10, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 10, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jarrednorrisdev
jarrednorrisdev marked this pull request as ready for review October 10, 2026 17:26
@jarrednorrisdev
jarrednorrisdev merged commit 5c740ad into main Oct 10, 2026
16 checks passed
@jarrednorrisdev
jarrednorrisdev deleted the fix/reader-own-build branch October 10, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant