Skip to content

feat(signals): A28 — a write becomes visible at flush, to every channel (read-side) - #3473

Merged
ryansolid merged 2 commits into
nextfrom
spike/a28-read-side
Sep 15, 2026
Merged

ryansolid merged 2 commits into
nextfrom
spike/a28-read-side

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Rule

A28. Between set(x) and the flush that carries it, the write is unflushed: not the committed value, not the staged value latest() / isPending() serve, and not an input to a derivation created meanwhile. latest(x) answers the flushed staged world; isPending(x) is false; until()'s predicate evaluated in the carrying flush sees it. Optimistic writes match (A28 (5), "match React"): setOptimistic(v) becomes the active override at the carrying flush — plain reads, snapshot(), in, keys, length, isPending() see nothing before — while the writer's own channels (functional updater, store draft, affects() declaration walk) compose on it. A rewrite of a held node keeps the staged value the last flush left for the verdict channels until the next flush. Companions and store keys first materialized under a hold are born as the holding transaction's (#3336).

Supersedes the #2922 mid-tick latest() pull (flush() first to read your own write). Supersedes #3337's mechanism.

Mechanism — read-side, plain write path untouched

  • "Unflushed" is structural (unflushedValue, core.ts): outside a flush, a node with an ambient staged value was written since the last flush — ambient staging commits at flush end, so nothing else leaves a node in that state. Exempt: engine companions (_parentSource), writes inside a creation-time recompute (CONFIG_PROMOTED).
  • Read sites test one module flag (unflushedStaged): set when a node is staged (queuePendingNode) or a held node rewritten outside a flush, cleared at flush start. Inside a flush it's one boolean that short-circuits.
  • Write-path arms are cold helpers gated on loads the write already pays: a rewrite of a stamped node → stashHeldRewrite; a write with context !== nullnotePromotedWrite; companion re-sync registration inside the branch only companion nodes take. setSignal stays inlinable (~360 B bytecode; the arms inline were 443 B and cost 10–20% on the write-loop benches — see below).
  • Readers served the flushed value are latched (REACTIVE_MISSED_WAKE) so the carrying flush re-runs them.
  • Companions re-sync at flush start (resyncUnflushedCompanions) so isPending()/latest() mirror the flushed world; a companion created lazily against an unflushed source joins that re-sync (joinUnflushedResync) — brought current by the optimistic write, so a derivation over latest() direct-commits as the optimistic view it is instead of being staged under whatever hold the round entered (a memo, and an effect, over latest() of a held source created mid-tick answered the previous value / never ran until the fetch settled).
  • Store half (2.0.0-rc.7 spooky action at a distance because of lazy latest computed creation #3336 lifted): visibleOverride, heldFoldTransition / foreignHold / heldFromReader for born-holding keys; optimisticView(draft) distinguishes writer from reader channels.

Performance

tests/core/reactivity.bench.ts, alternating pairs on a quiet machine, medians (the benches are flush(() => { N writes }), so the write loop is the benchmark):

bench Δ vs next
update1to1 +4.5%
update1to1000 +4.0%
propagation:diamond +2.3%
propagation:avoidable −1.0%
creation noise

An earlier shape of this branch measured −13…−19% on diamond/avoidable: setSignal's bytecode had grown 303 → 443 B (TurboFan inline limit 460, cumulative 920) and the A28 arms ran on every outside-flush write. Both fixed as above.

Size

Core floor 23,750 → 24,478 minified (+728 B; conscious bump, notes in treeshake.test.ts and .size-limit.js). Brotli: +179…+339 B across the nine scenarios.

Tests

  • latest-held-till-flush.test.ts (A28 (1)–(4), held and ambient writes, companions created after the write, 2.0.0-rc.7 spooky action at a distance because of lazy latest computed creation #3336 born-holding; the memo/effect-over-latest() cases above), born-held.test.ts, body-end-supersession-visibility.test.ts, superseded-before-first-commit.test.ts.
  • Both visibility oracles: every pre-flush cell reads the rule; no violation cells remain.
  • Re-pinned: latest-repeated-writes (2.0.0-beta.20: latest() only works once on memos #2922), latest-unobserved-memo, latest-probe-order-independence, isPending-memo-consistency, latest-plain-write-purity, projection-transition-isolation, createOptimisticStore, snapshot-derived-store-rows, question-scoped-pending, optimistic-store-layer-scope, shallow.
  • Spec: A28 ruled + mechanism; A29 creation-time form; A7 amendment (latest() throws in every uninitialized scope). OL-R2 / OS-R1 / CS-R34 superseded. Rules index regenerated.

signals 2,070 / solid 621 / web 804 green on the rebase over #3464#3471.

…el (read-side)

Between set(x) and the flush that carries it the write is unflushed: not the
committed value, not the staged value latest()/isPending() serve, and not an
input to a derivation created meanwhile. Optimistic writes match (A28 (5)):
setOptimistic(v) becomes the active override at the carrying flush; the
writer's own channels compose on it. A rewrite of a held node keeps the
staged value the last flush left for the verdict channels until the next
flush. Companions and store keys first materialized under a hold are born
as the holding transaction's (#3336).

Landed as a read-side rule rather than #3337's deferred subscriber walk:
"unflushed" is structural (an ambient staged value outside a flush), read
sites test one module flag (`unflushedStaged`, set when a node is staged or
a held node rewritten outside a flush, cleared at flush start), and the
write-path arms are cold helpers gated on loads the write already pays
(`_transition`, `context`) — setSignal stays within every setter's inlining
budget (~360 B bytecode; the arms inline cost 140 B and 10–20% on the
write-loop benches). Readers served the flushed value are latched for the
carrying flush (REACTIVE_MISSED_WAKE).

A companion created lazily while its source carries an unflushed write joins
the flush-start re-sync like one that existed at the write, so a derivation
over latest() direct-commits as the optimistic view it is rather than being
staged under whatever hold the round entered (a memo — and an effect — over
latest() of a held source created mid-tick answered the previous value, or
never ran, until the fetch settled).

Supersedes the #2922 mid-tick latest() pull (flush() first to read your own
write); OL-R2 / OS-R1 / CS-R34 superseded. Spec: A28 ruled + mechanism,
A29 creation-time form, A7 amendment (latest() throws in every uninitialized
scope). Oracles: every pre-flush cell reads the rule; no violation cells
remain. Size: core floor 23,752 → 24,480 (conscious bump, notes in
treeshake.test.ts and .size-limit.js).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 749de32

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coveralls

coveralls commented Sep 15, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35016221428

Coverage remained the same at 71.619%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1014
Covered Lines: 772
Line Coverage: 76.13%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 15.0 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 15, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 8.04%

⚡ 1 improved benchmark
✅ 174 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
createStore setter: delete + set one root key (#3044 overlay) 604.3 µs 559.3 µs +8.04%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing spike/a28-read-side (749de32) with next (3ae9e92)

Open in CodSpeed

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	scripts/size/.size-limit.js
@GabbeV

GabbeV commented Sep 15, 2026

Copy link
Copy Markdown

AI-assisted differential fuzzing found regressions against next (3ae9e921). These examples were independently reproduced outside the fuzzer, in development and production, and rechecked on 749de32d.

The sketches below omit root/initialization boilerplate. Each starts with async data initialized to 0; tick() advances to another task, and requests are manually controlled.

A. An existing effect receives a torn tuple

// count = 0, show = false
const details = createMemo(() => request(count()));
const copy = createMemo(count);

createRenderEffect(
  () => show() ? [count(), copy(), details()] : null,
  value => { log.push(value); }
);
createRenderEffect(details, () => {});

// After initialization:
setCount(1);
setCount(0);
await tick();
setShow(true);
setCount(1);
await tick();
resolveDetails(1);
await tick();

This PR delivers [1, 0, 1], then [1, 1, 1]. next only delivers [1, 1, 1].

B. A visibility update is permanently lost

// count = 0, show = false
const details = createMemo(() => request(count()));
createRenderEffect(show, recordShow);
createRenderEffect(() => show() ? details() : "hidden", recordPanel);

// After initialization:
setCount(1);
await tick();
setShow(true);
await tick();
setShow(false);
resolveDetails(1);
await tick();
setShow(true);
await tick();

On this PR, show() remains false and the panel stays hidden after everything settles. On next, it becomes true and shows 1.

C. Untracked latest() reads allow a stale derivation to publish

// count = 0, show = true
const details = createMemo(() => request(count()));
const copy = createMemo(details);

// Separately observe count(), details(), and:
// show() ? copy() : "hidden"
// The direct details reader has its own disposable root.

setShow(false);
await tick();
setCount(1);
latest(copy);
await tick();
setShow(true);
latest(copy);
disposeDetailsReader();
await tick();

This PR publishes count = 1 alongside copy = 0, before the request resolves. next keeps count at 0 and the conditional hidden, then publishes consistently when the request resolves.

Restoring only setSignal’s previous outside-flush adoption of an existing transition makes A and B pass. C still fails with that diagnostic change, so there appears to be another mechanism involved. This is causal evidence, not a proposed fix—the adoption change also serves the new isolation semantics.

There are also regressions in releasing unobserved work and a change to grouping when repeating an already-held signal write, but the correctness failures above seem like the clearest starting point.

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.

3 participants