Skip to content

fix(signals): a fold repeats no unchanged presence to a held node (#3743, supersedes #3744) - #3791

Merged
ryansolid merged 2 commits into
nextfrom
port/3744-presence-folds
Oct 5, 2026
Merged

ryansolid merged 2 commits into
nextfrom
port/3744-presence-folds

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Port of #3744 (@brenelz) onto next after the L2 hold model (#3774). The PR's tests are taken verbatim (plus three pins the port needed); the fix is re-implemented in L2's fold/notify path. Supersedes #3744. Co-authored-by: Brenley Dueck.

Closes #3743

What the issue was, on L2

notifyFoldTail (the fold's presence loop) wrote every observed in node with key in neu, unconditionally. L2's setSignal joins a held node's transaction before its equality gate — A34 (1): a write to a held node is a proposal, the same value or another. So a reconcile() outside an action that had deleted an observed key — the snapshot leaving it absent too — made the whole mainline tick the action's: an unrelated a.value written on the same tick stayed stale until the action settled (#3743).

The fix

  • Presence is diffed old → neu like the leaves (the fold's contract, Solid 2: Same-batch store reset leaves memos and effects holding cancelled values #3296: notify against the view the nodes were last told). A live chained old (a store proxy, §7b) is written unconditionally as before. notifyFold shares the tail (notifyFoldTail).
  • applyAdopt materializes a nested overlay draft before capturing its diff base (adoptPB did so only after the base was taken). Through the overlay's prototype a deleted key still read as present, so the presence skip fired on a real deletion. This also fixes a pre-existing value-loop bug: a reconcile restoring the key never re-proposed on the held leaf, which committed the draft's undefined beside a backing that had the key (pinned: "value twin").

Two fixes beyond the PR (maintainer rulings)

  1. Container-guess judgement is not gated on subscribers — both halves kept (fold path notifyFoldTail and setter path notifyWrites). A container carrying an arrangement guess (CONFIG_OVERRIDE) is told of an arrangement change whether or not anything subscribes to it: for a guessed container the write is the landing that judges the guess (A18; Q-D, plan sec. 39). Before, the unconditional presence write was the one landing that reached the lane; with presence diffed, a newer question's rows landing beneath an optimistic push published beside it for a reader of leaves alone. Forced by the existing createOptimisticStore pin "2.0.0-beta.14: Optimistic updates tearing fresh data #2719/2.0.0-rc until doesnt entangle the update resolving it with the action update #3164 holds optimistic rows across a separate source landing" going red after the diff; pinned on both paths by two new leaf-only-reader tests.
  2. _laneRebase compares by key, never by raw identity: a slot whose row the committed backing already shows (the same key's new object — its target adopted it) stays the committed backing's, and length is re-staged only when the committed length differs. Forced by signal-store-twins-qd's store case emitting a spurious identical frame after (1). Doc comment says "by key" explicitly.

Open, pinned it.fails

"a later mainline setter that does not write the held key publishes on the mainline tick" — the setter-path case from the #3743 thread. Spec (A34 (1)): an explicit repeat of the held key joins; a key the later setter did not write publishes on the mainline tick. Current L2 joins: t.wk (the written keys notifyWrites visits) is retained for the pending fold and cumulative across the batch's setters, so the later setter re-notifies the held node with its unchanged value and setSignal joins before the gate. Fix direction: per-setter written keys for the notify; the fold keeps the batch's union. Not fixed here.

Spec touches

SPEC-ASYNC-SEMANTICS.md: A34 Mechanism (L2) gains "a store fold's notifications are diffs (#3296, #3743)…"; A18 Mechanism (L2) gains the _laneRebase by-key clause and "a store landing reaches a guess through the fold's diff… the container's arrangement guess is written by every landing that changes the arrangement — whether or not anything subscribes". A34 "Pinned by" lists the new file. RULES-INDEX.md regenerated.

Public API changes

None.

Tests

New file tests/store/unchanged-presence-no-hold-3743.test.ts: 9 tests — the PR's 5 verbatim (issue case, contrast, two wide-object overlay cases, chained projection), plus the value twin, the two leaf-only-reader container-guess pins (fold path, setter path), and the it.fails above.

  • signals: 4900 passed / 0 failed / 3 expected fails / 2 skipped (rules-index.mjs --check green)
  • solid: 819 / 0
  • web: client 1131 + 1 pre-existing expected fail, server 1411 / 0 (2 skipped), hydrate 275 / 0

Performance

reconcile-dbmon / listened-paths / write-floor on the prod dist vs a next build, back-to-back: no consistent direction. The machine was shared with another build during the window (load 6–12); the next baseline itself swung ±25% between pairs and every port-vs-next delta flipped sign across runs. In the quietest pair: dbmon ticks −2…−3% (within ±9–12% rme), all ~12k paths +2%, owned backings +1%. Nothing in the change touches a hot loop (the setter-path addition is one _config bit test on the container gate). CodSpeed is the real number.

Size

Brotli vs next @ 1a3f87f (Rolldown 1.2.11), minified delta where it changed:

scenario next this PR Δ br cap Δ min
signals: + createStore 14,519 14,524 +5 14,530 −97
app: hydrating + every store family 28,755 28,757 +2 28,780 −81
page: base server components 44,029 44,031 +2 44,030 → 44,050 −97
page: live server components 47,654 47,649 −5 47,670 −97
all 10 other scenarios — — +0 ok +0

Page base was 1 B over its frozen cap (brotli layout on the 150 KB bundle; the same bundle is −97 B minified). Raised 44.03 → 44.05 KB (measured + 10 B, rounded up to 0.01 KB) in a separate commit with a dated note in scenarios.js.

Size-Exception: page: base server components 44,029 -> 44,031 B (cap 44.03 -> 44.05 KB) — presence diff in store folds (#3743); source −97 B minified; accepted by the maintainer (2026-10-04)

@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b32268c

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

This PR includes changesets to release 12 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
todos-server-example 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

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.32 KB 0 B 7.33 KB ✅
signals: + createStore 14.52 KB +5 B (+0.0%) 14.53 KB ✅
signals: + isPending/latest 9.44 KB 0 B 9.45 KB ✅
app: render + one signal (the simple-app floor) 9.80 KB 0 B 9.81 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.63 KB 0 B 17.64 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.76 KB +2 B (+0.0%) 28.78 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.81 KB 0 B 12.82 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.38 KB 0 B 14.39 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.60 KB 0 B 28.61 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.00 KB 0 B 13.00 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.03 KB +2 B (+0.0%) 44.05 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 47.65 KB −5 B (−0.0%) 47.67 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 20.42 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body).

@coveralls

coveralls commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37265932281

Coverage remained the same at 75.991%

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: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.9 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 12.17%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 186 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 32 ms 26.9 ms +19.26%
⚡ dbmon shallow full tick 53.3 ms 50.6 ms +5.51%

Tip

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


Comparing port/3744-presence-folds (b32268c) with next (b61eef6)

Open in CodSpeed

ryansolid and others added 2 commits October 4, 2026 22:00
)

Port of #3744 (brenelz) onto L2 (#3774). The fold's presence loop
(`notifyFoldTail`) wrote every observed `in` node with `key in neu`;
`setSignal` joins a held node's transaction before its equality gate
(A34 (1): a write to a held node is a proposal, the same value or
another), so a `reconcile()` outside an action that had deleted an
observed key — the snapshot leaving it absent too — made the whole
mainline tick the action's: an unrelated `a.value` stayed stale until
the action settled. Presence is now diffed `old` -> `neu` like the
leaves (the fold's contract, #3296: the view the nodes were last told);
a live chained `old` (a store proxy) is written unconditionally as
before. `notifyFold` shares the tail.

`applyAdopt` materializes a nested overlay draft before taking its diff
base (`adoptPB` did so after it was taken): through the overlay's
prototype a deleted key still read as present, so the presence skip
fired on a real deletion, and a reconcile restoring the key never
re-proposed on the held leaf, which committed the draft's `undefined`
beside a backing that had the key.

Two L2 fixes the diff uncovered, pinned by the existing #2719/#3164 and
Q-D twin tests and a new leaf-only reader case: a container carrying an
arrangement guess is told of an arrangement change whether or not
anything subscribes to it (`notifyFoldTail`/`notifyWrites`) — for a
guessed container the write is the landing that judges the guess
(Q-D, plan sec. 39), and before only the unconditional presence write
reached the lane, so a newer question's rows landing beneath an
optimistic push published beside it for a reader of leaves alone; and
`_laneRebase` no longer re-stages a slot whose row the committed
backing already shows by key, nor an unchanged `length` (a spurious
identical frame).

Signals 4900 / 0 / 3 expected fails (+9 pins: 8 green, one `it.fails`
for the open setter-path case — `t.wk` is cumulative, not per-setter),
solid 819 / 0, web 1132 / 1411 / 275, 0
failures. Size (br): + createStore 14517 -> 14523, every store family
28770 -> 28765, page base 44017 -> 44028, page live 47656 -> 47668
(minified -97..-100 B each); every scenario within its cap.

Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The port of #3744 measures 44,031 B brotli on the base server-components
page against next @ 1a3f87f's 44,029 (+2 B, 1 B over the cap) while the
same bundle is -97 B minified: brotli layout on the 150 KB bundle, not
code. Cap set at measured + 10 B rounded up to 0.01 KB; dated note in
scenarios.js beside the page-base notes.

Size-Exception: page: base server components 44,029 -> 44,031 B (cap 44.03 -> 44.05 KB) — presence diff in store folds (#3743); source −97 B minified; accepted by the maintainer (2026-10-04)
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the port/3744-presence-folds branch from 840fe3e to b32268c Compare October 5, 2026 05:00
@ryansolid
ryansolid merged commit 924d909 into next Oct 5, 2026
7 checks passed
ryansolid added a commit that referenced this pull request Oct 5, 2026
…e base 44.05 -> 44.78, page live 47.67 -> 48.45

Refetched content lands at the transition's commit (the staging, the
content token, the two halves of the mount's follow effect, the staged
data tables, and on L2 the switch's rebind in the effect half with the
frameless host waiter). Measured against `next` @ bde4299 (after
#3790/#3791): frames 12,997 -> 13,770 B (+773; +2,262 B minified, frames
client +2,255), page base 44,048 -> 44,762 B (+714; +2,265 B minified),
page live 47,670 -> 48,436 B (+766; +2,265 B minified). Caps set at
head + 10 B. Ledger notes in scenarios.js re-applied from the PR's
1b347c7 against these numbers, after #3743's page-base note.

Size-Exception: frames: eager client consumer 12,997 -> 13,770 B (cap 13.00 -> 13.78 KB); page: base server components 44,048 -> 44,762 B (cap 44.05 -> 44.78 KB); page: live server components 47,670 -> 48,436 B (cap 47.67 -> 48.45 KB) — #3759's staging so refetched content lands at the transition's commit; accepted by the maintainer (2026-10-04).

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 5, 2026
* frames: refetched content lands at the transition's commit (#3759, ported onto L2)

A refetch or single-flight region for a call a boundary is showing is
staged in the transport and the call resolves to a content token; the
mount lands it in the two halves of the render effect that follows its
address accessor. Under the hold model the two halves ride core's seams
unchanged: the compute half is the delivering transaction's pass (its
`preview` writes — the staged slot args — are held with it and commit in
the frame whose landing dissolves the lane's guesses), the effect half is
its `land` (the commit replays the staged chunks). Both signals pins
(`compute-write-joins-transition`, `settle-folds-queued-writes`) and the
optimistic-hold specs were green on untouched L2; the morph-in-transition
specs were red (3/3) and are closed by the staging.

Composes with e133516 (the switch gate re-arms in the pass): the rebind
— display — moves to the effect half so a switch's content waits for the
commit too (`frames-morph-in-transition` switch case), while the gate
settles on the new address's first write through a frameless waiter
registered on the host, so a second switch mid-flight still binds
(`call-driven-lifecycle`). No core seam touched. Size caps not raised:
frames 12,997 -> 13,770 B (cap 13.00 KB), page base 44,017 -> 44,765,
page live 47,656 -> 48,447 — reported for the maintainer's exception.

Signals 4898/0/2, web 1138/0 + server 1411/2 skipped + hydrate 275/0.

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

* size: Size-Exceptions for #3759 on L2 — frames 13.00 -> 13.78 KB, page base 44.05 -> 44.78, page live 47.67 -> 48.45

Refetched content lands at the transition's commit (the staging, the
content token, the two halves of the mount's follow effect, the staged
data tables, and on L2 the switch's rebind in the effect half with the
frameless host waiter). Measured against `next` @ bde4299 (after
#3790/#3791): frames 12,997 -> 13,770 B (+773; +2,262 B minified, frames
client +2,255), page base 44,048 -> 44,762 B (+714; +2,265 B minified),
page live 47,670 -> 48,436 B (+766; +2,265 B minified). Caps set at
head + 10 B. Ledger notes in scenarios.js re-applied from the PR's
1b347c7 against these numbers, after #3743's page-base note.

Size-Exception: frames: eager client consumer 12,997 -> 13,770 B (cap 13.00 -> 13.78 KB); page: base server components 44,048 -> 44,762 B (cap 44.05 -> 44.78 KB); page: live server components 47,670 -> 48,436 B (cap 47.67 -> 48.45 KB) — #3759's staging so refetched content lands at the transition's commit; accepted by the maintainer (2026-10-04).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.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.

2 participants