perf(dom): paint a reactive region's first emission during mount (#182) - #183
Open
stefvw93 wants to merge 1 commit into
Open
perf(dom): paint a reactive region's first emission during mount (#182)#183stefvw93 wants to merge 1 commit into
stefvw93 wants to merge 1 commit into
Conversation
A reactive region placed only its markers during the mount pass and deferred
its first content to the Loom flush fiber, which the Effect scheduler resumes
one macrotask later. A `List.each` over a static 10-item array showed 0 items
when `mount` resolved, 0 after 200 microtasks, and 10 only after a
`setTimeout(0)`, so every region painted a frame after its synchronous
siblings.
The pump is now forked with `{ startImmediately: true }` during a mount pass,
so a source whose first element is synchronously available delivers it during
the `forkIn` call. `makeInlineHeadPump` routes that element into a capture slot
instead of the Loom cell; the call site renders it inline and returns it in the
node list, so it paints in the mount frame.
This is a single subscription. There is no probe and no re-subscription, so a
side-effecting source still runs exactly once, and no source-kind
discrimination is needed: anything that can deliver synchronously does, and
anything that cannot leaves today's behaviour byte-for-byte intact. Static
values, synchronous Effects, `SubscriptionRef`/`Subscribable` change streams
and synchronous cold Streams all qualify.
Applied at all three sites: `renderList`, `handleStreamChild`, and
`subscribeToStream` (reactive props).
Only the first emission changes. Later emissions still go through the cell and
the flush fiber, so reconcile semantics, ListState, conflation, ordering,
supervision, teardown and error routing are unchanged. `commitGeneration` still
counts flush passes only, so an inline paint does not advance it.
- `LoomCell.markCommitted` records the inline paint without a flush pass, so a
suspense fallback settles tick-free and `onDiscard` cannot fire for a region
that produced content.
- `LoomCell.reportAndDiscard` routes an inline render failure exactly as the
flush pass routes a failed commit, including the interrupt-only filter, so
`mount` still succeeds and boundary recovery is unchanged.
- `RenderContext.syncFirstPaint` gates the flag to the mount pass. It is
cleared in Loom commits, in hydration, and in forked continuations that
render (a boundary fallback, an rpc-resolved subtree) via `forkRendering`.
The gate is defensive rather than a fix for an observed leak: #179 does not
reproduce on effect 4.0.0-beta.98 (verified, see the comment on that issue), so
its regression test is an invariant guard, not a red-green proof.
No public API changed. The helper, the flag and both cell members are internal
and absent from the emitted declarations.
Note: the regenerated graphify-out/ artifacts are omitted from this commit.
They are ~105k lines of generated graph data that would bury a 1.5k-line
feature in review, and a diff that size fails PR creation server-side.
Run `graphify update .` after merge to refresh them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #182.
Problem
A reactive region places only its markers during the mount pass and defers its first content to the Loom flush fiber, which the Effect scheduler resumes one macrotask later. Measured with a
List.eachover a static 10-item array:mountresolvessetTimeout(0)So every region painted a frame after its synchronous siblings, even when the source's value was available all along.
Approach
The pump is forked with
{ startImmediately: true }during a mount pass, so a source whose first element is synchronously available delivers it during theforkIncall.makeInlineHeadPumproutes that element into a capture slot instead of the Loom cell; the call site renders it inline and returns it in the node list, so it paints in the mount frame.This is a single subscription. There is no probe and no re-subscription, so a side-effecting source still runs exactly once, and no source-kind discrimination is needed: anything that can deliver synchronously does, and anything that cannot leaves today's behaviour byte-for-byte intact. There is no fallback branch.
Qualifying sources, measured against
effect@4.0.0-beta.98: static values/arrays, synchronousEffects,SubscriptionRef/Subscribablechange streams, and synchronous coldStreams. Async sources are unchanged.Applied at all three sites:
renderList,handleStreamChild, andsubscribeToStream(reactive props).Design note: this replaced the original plan
The approved spec first used a
Stream.runHeadprobe gated by source kind. Two measurements retired it mid-implementation:SubscriptionRef.changes(ref), aStream, which that gating excluded. Every example uses it, so the headline case would have gotten no fast path.Stream.fromEffect(Effect.sync(f))source twice.The spec carries an
## Amendmentsentry recording the withdrawn criteria.Invariants
Only the first emission changes. Later emissions still go through the cell and the flush fiber, so reconcile semantics,
ListState, conflation, ordering, supervision, teardown, and error routing are unchanged.commitGenerationstill counts flush passes only.LoomCell.markCommittedrecords the inline paint without a flush pass, so a suspense fallback settles tick-free andonDiscardcannot fire for a region that produced content.LoomCell.reportAndDiscardroutes an inline render failure exactly as the flush pass routes a failed commit, including the interrupt-only filter (LM18), somountstill succeeds and boundary recovery is unchanged.RenderContext.syncFirstPaintgates the flag to the mount pass. Cleared in Loom commits, in hydration, and in forked continuations that render (a boundary fallback, an rpc-resolved subtree) viaforkRendering.No public API changed. The helper, the flag, and both cell members are internal and appear zero times in the emitted
dist/*.d.tsfor all three entry points.On #179
The
syncFirstPaintgate exists because #179 reports thatstartImmediatelyforks are not interrupted by scope close in a nested-fork topology. That does not reproduce oneffect@4.0.0-beta.98(verified; see the comment on #179). The gate is kept because it costs nothing and states the semantics honestly, but it is defensive rather than a fix for an observed leak, and its regression test is labelled an invariant guard, not a red-green proof. #179 should be re-derived or closed before any upstream report.Tests
first-paint.test.ts(23) covers CW1-CW6, FP1-FP7, MG2/MG3, FE1-FE4, AS1-AS3, HS1.loom.test.tsgains LC1-LC5.first-paint.browser.test.tsis the permanent guard. Its positive assertions deliberately avoidvi.waitFor: the claim is about when the paint happens, and a retrying matcher would pass with the bug present. The DOM is snapshotted synchronously atmountresolve. Verified as a real red-green signal by forcingsyncFirstPaint: false, which fails it withexpected +0 to be 10.Gates:
vp run check,vp run test(825),vp run test:types(149 tests / 251 assertions),vp run test:browser(71) all green. Verified against the committed tree in isolation, with unrelated local WIP stashed, so nothing here depends on uncommitted state.Note on
graphify-out/The regenerated graph artifacts are deliberately not in this commit. They are ~105k lines (a 3.3 MB dated backup plus a 3.2 MB
graph.json) against a 1.5k-line feature, which would make the diff unreviewable. Rungraphify update .after merge to refresh them.Relationship to open PRs
Branches off
mainindependently of #180 and #181. Verified withgit merge-tree: this branch merges cleanly against both, with no conflicts inrenderListor anywhere else.The interaction is also semantically benign. The inline path calls
reconcileListagainst empty state, so it simply uses whichever mount fast path exists once #180 lands (that PR'smountListFastempty-prev branch), and #181's append fast path is orthogonal since it triggers only on a growing non-empty previous order.Docs
loom.specs.md("all commits async" now means all flush-pass commits) andlist.specs.mdamended via the pause rule.docs/explanation/rendering-model.mdanddocs/reference/dom.mdupdated; the Loom heading was left intact so the deep link fromreactive-primitives.mdstill resolves.🤖 Generated with Claude Code