Skip to content

perf(dom): paint a reactive region's first emission during mount (#182) - #183

Open
stefvw93 wants to merge 1 commit into
mainfrom
feat/sync-first-paint
Open

perf(dom): paint a reactive region's first emission during mount (#182)#183
stefvw93 wants to merge 1 commit into
mainfrom
feat/sync-first-paint

Conversation

@stefvw93

Copy link
Copy Markdown
Owner

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.each over a static 10-item array:

moment items in DOM
mount resolves 0
after 200 microtasks 0
after one setTimeout(0) 10

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 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. There is no fallback branch.

Qualifying sources, measured against effect@4.0.0-beta.98: static values/arrays, synchronous Effects, SubscriptionRef/Subscribable change streams, and synchronous cold Streams. Async sources are unchanged.

Applied at all three sites: renderList, handleStreamChild, and subscribeToStream (reactive props).

Design note: this replaced the original plan

The approved spec first used a Stream.runHead probe gated by source kind. Two measurements retired it mid-implementation:

  • The idiomatic Weft form is SubscriptionRef.changes(ref), a Stream, which that gating excluded. Every example uses it, so the headline case would have gotten no fast path.
  • A probe plus pump re-subscription provably runs a Stream.fromEffect(Effect.sync(f)) source twice.

The spec carries an ## Amendments entry 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. commitGeneration still counts flush passes only.

  • 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 (LM18), so mount still succeeds and boundary recovery is unchanged.
  • RenderContext.syncFirstPaint gates 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) via forkRendering.

No public API changed. The helper, the flag, and both cell members are internal and appear zero times in the emitted dist/*.d.ts for all three entry points.

On #179

The syncFirstPaint gate exists because #179 reports that startImmediately forks are not interrupted by scope close in a nested-fork topology. That does not reproduce on effect@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.ts gains LC1-LC5.
  • first-paint.browser.test.ts is the permanent guard. Its positive assertions deliberately avoid vi.waitFor: the claim is about when the paint happens, and a retrying matcher would pass with the bug present. The DOM is snapshotted synchronously at mount resolve. Verified as a real red-green signal by forcing syncFirstPaint: false, which fails it with expected +0 to be 10.
  • Tests that assert preserved behaviour (MG3, FE1-FE4, AS2) are labelled in-file as invariant guards, green before and after, rather than contorted into artificial failures.

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. Run graphify update . after merge to refresh them.

Relationship to open PRs

Branches off main independently of #180 and #181. Verified with git merge-tree: this branch merges cleanly against both, with no conflicts in renderList or anywhere else.

The interaction is also semantically benign. The inline path calls reconcileList against empty state, so it simply uses whichever mount fast path exists once #180 lands (that PR's mountListFast empty-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) and list.specs.md amended via the pause rule. docs/explanation/rendering-model.md and docs/reference/dom.md updated; the Loom heading was left intact so the deep link from reactive-primitives.md still resolves.

🤖 Generated with Claude Code

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>
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.

perf(dom): reactive-region first-paint latency (synchronous first emission)

1 participant