Skip to content

Stage 2: store patch channel + patch-mode list driver (dormant by default) - #3079

Merged
ryansolid merged 28 commits into
nextfrom
stage2-channel
Aug 28, 2026
Merged

Stage 2: store patch channel + patch-mode list driver (dormant by default)#3079
ryansolid merged 28 commits into
nextfrom
stage2-channel

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

The stage-2 patch channel: compiled patch bodies (patchDriver) dispatch DOM writes directly from store visibility transitions, and a patch-mode list driver (driveList) drives keyed store arrays structurally through row ops — bypassing per-binding effects and the second (DOM-side) diff. Dormant by default: the compilers ship the grammar off; enabling is a patchDriver option (or a future default flip). Design record: packages/signals/DESIGN-PATCH-CHANNEL.md (§1–§20).

  • Store channel (@solidjs/signals): per-record patch registration, transition-stamped apply queue (effect-phase timing, merge-safe held stashes, optimistic lane timing + revert resync), row-ops/slot channels from the reconcile walk and setter folds, family (projection/optimistic) emission at fold-commit, accessor exclusion at admission + demotion to tracked effects.
  • List driver (@solidjs/web): compile-time row-proof admission, LIS moves, per-row unbinds, view-declared identity semantics (shallow reference-keyed rebuilds), hydration claim, late-classic handoff when a subject leaves the contract.
  • Compilers: eligibility analysis + wrapPatchMode/rowProof stamping in both Babel and Oxc, byte-identical (dom-patch parity tier, zero ratchet files); patchDriver typed and boolean-normalized identically in both loaders.
  • Pay-for-use, two tiers: classic apps retain only a ~100 B insert seam (store floors +~490 B of trap/walk seams). Under patch mode, non-list templates add 1.5 kB brotli (value tier); only compiled list rows pull the list driver (+2.1 kB more). Flip-preview size scenarios pin both tiers.

Numbers

  • octane dbmon (deep store, driver on): mount 15.8→6.3 ms, tick 8.3→1.8, partial 1.4→0.6, remount 10.1→5.0
  • svg-dashboard: every op flat-or-better (mount −23%, charts_tick −21%)
  • Known cost: portal-swarm open/close +~5% with patch mode ON (signal-only fixture pays the effect-fallback probe per mount) — a default-flip consideration, not a merge one
  • Tier-1 store benches: parity within noise (8 interleaved rounds); size gate green on all 10 scenarios

Test plan

  • Full monorepo suite (32/32 tasks): client, server, hydrate, compiler parity, export parity
  • Driver/classic equivalence matrix (for.equivalence.spec.tsx): identity modes × operation sequences, DOM content + retention topology
  • Patch-channel gauntlet incl. re-audit hardening: owner-tied unbinds, transition merges, optimistic error isolation/boundary routing, accessor admission/demotion, writable-projection structure, duplicate keys
  • Babel↔Oxc dom-patch parity (108/108 byte-identical)
  • size-limit scenarios incl. flip previews
  • CodSpeed on this PR confirms tier-1 parity on a clean machine

Made with Cursor

@changeset-bot

changeset-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4135eff

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 Major
solid-js Major
@solidjs/web Major
@solidjs/babel-plugin Major
@solidjs/compiler Major
test-integration Patch
@solidjs/element Major
@solidjs/h Major
@solidjs/html Major
@solidjs/universal Major
@solidjs/diagnostics Major

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 Aug 27, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.67 KB (+1.06% 🔺)
signals: + createStore 13.98 KB (+5.69% 🔺)
signals: + isPending/latest 9.6 KB (+0.76% 🔺)
app: render + one signal (the simple-app floor) 10.36 KB (+1.17% 🔺)
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.07 KB (+1% 🔺)
app: hydrating + every store primitive family 25.38 KB (+4.33% 🔺)
app: CSR with Show/For/Loading/Errored/lazy 12.53 KB (+1.57% 🔺)
frames: eager client consumer (frames client + transport, lazy codec) 10.64 KB (0%)
app: CSR flip preview — + patchDriver (non-list patch templates) 14.25 KB (+100% 🔺)
app: CSR flip preview — + rowProof (patch-mode list driver) 16.43 KB (+100% 🔺)

@coveralls

coveralls commented Aug 27, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 33193957269

Coverage decreased (-0.6%) to 71.652%

Details

  • Coverage decreased (-0.6%) from the base build.
  • Patch coverage: 9 uncovered changes across 1 file (0 of 9 lines covered, 0.0%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/solid/src/client/flow.ts 9 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1003
Covered Lines: 767
Line Coverage: 76.47%
Relevant Branches: 789
Covered Branches: 517
Branch Coverage: 65.53%
Branches in Coverage %: Yes
Coverage Strength: 14.54 hits per line

💛 - Coveralls

@codspeed-hq

codspeed-hq Bot commented Aug 27, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 1.59%

⚠️ 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
❌ 1 regressed benchmark
✅ 133 untouched benchmarks
⏩ 132 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
merge 71.5 µs 138.2 µs -48.23%
omit 320 µs 235.6 µs +35.83%
merge 362.8 µs 267.7 µs +35.53%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing stage2-channel (4135eff) with next (c07edcb)

Open in CodSpeed

Footnotes

  1. 132 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

ryansolid and others added 20 commits August 27, 2026 15:07
…gainst folded next

Co-authored-by: Cursor <cursoragent@cursor.com>
…ion raced row creation on appends

The audit's merge gate (PROPOSAL §8.1), built: every identity mode ×
operation sequence runs twice — a hand-compiled patch-mode row (driver
engaged) and the same DOM under a grouped render effect, unstamped
(driver declines, classic mapArray owns the list) — and the per-step
traces must be IDENTICAL: content, order, and retention topology
(each position: new node, or moved from position j). Traces are
normalized so creation-serial order cannot fake or mask divergence;
payload object graphs are cloned per run (stores adopt/own incoming
data — sharing records between runs contaminates the second).

25 scenarios: aligned ticks, replacement (aligned and through moves),
adds, removes, reorders, clear+refill, pure appends, mixed batches,
churn, identity swaps — deep and shallow, plus retention-sanity pins
for deep key-retention and the shallow reference-rebuild ruling.

FIRST CATCH, within the hour: shallow slot emission used the
keyAligned flag as its guard, but the flag is only falsified below the
common length — appended positions past an aligned prefix (vacuously
aligned from an empty prev) emitted slot ticks for rows the row ops
had not created yet; the slot queue applies first and indexed
undefined entries (driver crash on clear-then-refill and pure
appends). Slots now require a previous slot (i < dlen); appends are
structure-only. Matrix extended with both crashing shapes.

matrix 25 / web 641 / signals 1364 green.

Co-authored-by: Cursor <cursoragent@cursor.com>
…egistration resolves the ultimate owner

Family-channel increment 1 (the 'benchmark-shaped' answer, part one).

Narrowing: the audit-era blanket family decline was broader than the
bug. The reconcile walk's row/slot emissions were never family-gated —
projection recomputes emit through the transition-stamped apply queue
like any plain store. Only OPTIMISTIC families have the structural gap
(user writes ride node overrides, no walk, no ops): the decline is now
storeHasOptimisticFamily; projections engage.

Real bug found by the extended matrix: projection rows are CHAINED
backings (§7b — the family wrapper's backing IS the source proxy).
registerPatch/patchableRaw resolved the WRAPPER target, but value
transitions fold on the SOURCE target — registered patches never fired
(matrix: driver rows froze on value ticks while classic tracked
through). Both now resolve through the chain to the ultimate owner;
accessor demotion re-checked at the resolved target.

Matrix: 13 projection scenarios added (all sequences + recompute-driven
structure + retention topology pin: filter drop keeps untouched nodes).
38 matrix / web 654 / signals 1364 / solid 561 green.

Remaining for increment 2: optimistic structural emission (lane-timed,
mirroring emitPatchOptimistic) + revert resync, then optimistic
re-admission.

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

Family-channel increment 2 (the 'benchmark-shaped' answer, part two).

Optimistic structural writes never enter the reconcile walk (they ride
node-level overrides), so the override-application site now emits
identity-diffed row ops at LANE timing — in-flight visibility,
bypassing the transition stash exactly like optimistic record patches
(emitRowOpsOptimistic beside emitPatchOptimistic; buildIdentityRowOps
factored from the setter channel — same retention semantics, different
dispatch timing). Reverts emit the RESYNC form (ops === null): the
driver rebuilds retention by row identity against the live post-revert
view, resolved from the target at drain (overrides are gone by then).

Driver: applyOps grows the resync mode reusing the identity-swap
matcher (now shared as identityOps); optimistic lists BIND from the
optimistic view through the proxy — the committed backing lags the
visible state in flight, and classic mapArray reads the same view (the
mount-under-optimism equivalence edge). Both decline sites removed;
swaps to optimistic arrays stay engaged.

Matrix: 9 async optimistic scenarios (push / splice / reorder+value /
whole-list replace × revert + land) snapshotting mounted → in-flight →
settled, plus the in-flight visibility pin. All 47 matrix scenarios
green on first run; web 663 / signals 1364 / solid 561.

The list driver now covers plain deep, shallow (reference semantics),
projection, and optimistic store arrays — every store kind. Remaining
stage-2 work is measurement (quiet-machine margins) and the two
deliberation items (coverage posture, ship shape).

Co-authored-by: Cursor <cursoragent@cursor.com>
…d; row-coverage folded-repo paths

Coverage report reproduces its pre-fold baseline exactly (5/39 lists
stamp, 13%) against the in-tree babel-plugin with patch mode opted in
explicitly (dormant default unaffected).

Co-authored-by: Cursor <cursoragent@cursor.com>
…pped in rebase

The previous rebase resolved the server spec conflicts to the pre-stub-
batching string assertions; restore upstream's VM-executed record-key
assertions and the export-value boot check (2f01f23).

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

Co-authored-by: Cursor <cursoragent@cursor.com>
…m fallback tax quantified

Co-authored-by: Cursor <cursoragent@cursor.com>
…on-patch apps

The list driver + patchDriver were hard-wired into web's client.ts
(insert called driveList directly) and the store write paths imported
patch.ts's emitters statically — every app paid ~2.4 KB brotli
(simple-app floor 10.41 -> 12.99), stores ~1 KB, and 5 of 8 size-limit
scenarios failed.

Now: web's driver lives in patch-driver.ts and arms an insert hook slot
lazily from rowProof/patchDriver (module-scope install won't do — the
dist is a flat bundle, so a top-level call is an unshakeable side
effect); signals' emitters install as hooks (patch-hooks.ts) at first
registration, sound because every emission site is pc-guarded and pc
only exists via registration. registerSlotPatchNext moved to patch.ts so
slot-only registrations arm too.

Size gate green on all 8 scenarios: app floors back to ~next (10.51 vs
10.41 — the ~100 B seam), store floors +~490 B of trap/walk seams
(limits ratcheted with notes). Driver engagement unchanged: dbmon tick
1.60, full suite 32/32.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…tion, families, parity

Blockers from the external stage-2 re-audit, each verified in code first:

1. Ordinary patchDriver registrations tie their unbind to owner cleanup
   (drains only SKIP disposed owners; entries and patchCount leaked).
   Unbind decrements only on actual removal so demotion can't double-count.
2. mergeTransitionState moves the _heldPatches sidecar like every declared
   per-transition collection — releaseBatch only reads the committing
   transition's stash, so merged-away stashes silently dropped patches.
3. drainOptimistic shares applyEntries with the normal drain: per-entry
   isolation + registering-owner boundary routing (one throw aborted
   siblings and bypassed Errored).
4. patchableRaw scans before trusting the accessor flag (it starts false
   and is discovered lazily); defineProperty-acquired accessors demote
   patches to tracked effect fallbacks via the hooks (getter deps now
   re-apply). demotePatches drains in place and repairs patchCount.
5. Writable projection arrays emit setter row ops at fold commit (both the
   clone branch and the eager write-override fold), gated off adoption
   folds (walk already emitted) and optimistic families (override channel).
   registerRowOps/registerSlotPatchNext resolve chained backings to the
   ultimate owner like registerPatch.
6. Occurrence-aware duplicate-key matching in buildRowOps and the driver's
   identityOps — first-wins reuse handed one DOM row to multiple positions.
7. _DX_DEV_ -> _SOLID_DEV_ (the web build replaces the latter; the dev
   ownership diagnostic shipped to production patch bundles).
8. Compiler contract: patchDriver in TransformOptions, Babel normalizes
   boolean true like the native loader, and a dom-patch parity tier runs
   the full dom corpus with patch mode on — byte parity, zero ratchet
   files. Branch rebased onto current next (5 commits, incl. #3068).

Full suite 32/32, size gate clean, dbmon driver engagement unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The whole-file replay carried pre-fix copies of three shipped fixes; a
file-by-file diff audit against origin/next found and restored them:

- #3013 follow-up: empty-string <select value> SSR resolution
  (web/src/server.ts SELECT_VALUE_ATTR bare-form handling + its two tests)
- stage-4 hydration allocation trim (server/hydration.ts one-pass context
  lookup, conditional RevealGroupContext severing)
- #3074/#3075 projection transition isolation + latest() parity, ported
  into the stage-2 store shape (hv/ht held-view fields join the pre-shaped
  TargetShape constructor beside the pc extension; PLAIN_HOLD clearing
  merged into drainFolds ahead of the foldedEager capture; size-limit
  ratchet notes merged).

Co-authored-by: Cursor <cursoragent@cursor.com>
- Restore .changeset/fix-select-empty-value-ssr.md (next's pending note,
  deleted by the replay).
- Prune changesets describing deleted iterations: the runtime purity probe
  (probe-abort-semantics, fix-probe-cascade — replaced by compiler-only
  rowProof admission) and the web-core rxcore seam exports
  (patch-driver-web-export, web-export-patch-seams — superseded by the
  pay-for-use driver module).
- jfb-driver-and-write-bound rewritten to only the driver identity unwrap
  (the written-keys bound already shipped from next).
- Parity harness comment: patch mode is dormant by default; the dom-patch
  tier is the explicit opt-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
…os pin the default-on cost

The default flip is the expectation, so the size gate must price that
world, not the dormant one. Two changes make the flip cost proportional:

- patchDriver no longer arms the list driver (only rowProof does — it is
  the compiled marker of a patch-mode list and the driver's only
  consumer), so the near-universal non-list patch templates never retain
  row binding/LIS/ops code.
- The signals emitters split into value hooks (armed by registerPatch)
  and row hooks (armed by registerRowOps/registerSlotPatchNext): a
  value-only registration no longer retains the row-ops emitters and
  reconcile's keyed/identity diff builders.

Two flip-preview scenarios pin the tiers against the classic CSR app
(12.71 KB brotli): + patchDriver 14.23 KB (+1.5 — registration, apply
queue/drains, error routing, demotion), + rowProof 16.31 KB (+3.6 total —
the list driver + row machinery, the tier the dbmon-class wins accrue
to). Full suite green.

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

CodSpeed flagged the 12k-path listened-paths reconcile at -7.7%: the
adoption walk's new emission guards loaded t.pc/t.pc.p per record even
with zero patch consumers. Guards now test the module hooks binding
first — one predictable branch per record when the channel is unused.
Local A/B back to parity (mins 0.152 vs 0.153 ms).

Co-authored-by: Cursor <cursoragent@cursor.com>
…y, error recovery, key equality, coalescing

All six findings verified in code first; each was real:

1. Adoption could hand accessor-bearing objects to registered patches
   (adoptPB resets the scan verdict; the walk emitted immediately).
   Both emission seams (walk + fold commit) now demote to tracked
   effect fallbacks via targetIsPlain — the one-time scan only runs on
   records that carry patches.
2. Setter-returned root replacements (setState(() => next)) and
   chained-store swaps adopted with NO emission (fold emission was
   family-only). Plain fold-adopted targets now emit patches + setter
   row ops at their fold commit.
3. applyOps builds every new row BEFORE removals/moves; a throwing row
   factory severs its staged registrations and leaves DOM + bookkeeping
   untouched (driver test proves atomic rollback + recovery).
4. Patch errors: StatusError source is the nearest computed ancestor
   (Errored.reset() recomputes sources — a plain listOwner crashed it);
   reset() skips non-computed sources defensively; unhandled patch
   errors haltReactivity like effects.
5. Key equality unified: SameValueZero + occurrence-aware in the
   adoption prefix, descend's detach check, the adoption window (index
   queues), root identity, and buildRowOps — NaN keys stay retained,
   duplicates adopt per occurrence on both channels.
6. Same-batch emissions coalesce (pc.qa/ql stamp): two setters, one
   application — effect parity for custom-element setters.

Size floors ratcheted with attribution (re-audit bytes on the store
floor; upstream notifier/#3057 drift on the app floors). Full suite
32/32.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid and others added 4 commits August 27, 2026 21:42
…tart, optimistic parity, failed-apply resync

All six findings verified; five real, fixed:

1. Coalescing applied STALE state: adoption replaces the captured next
   object per emission, so dropping later same-batch emissions applied
   the first object while the store held the last. pushSelf now UPDATES
   the queued entry in place (latest next, earliest prev — effect
   semantics) and refreshes the consumer list; the drain clears qa/qe
   stamps so quiet records retain nothing from their last batch (P2-6).
2. The adoption remainder window built its map from index 0, re-offering
   prefix-consumed rows to duplicate keys past an aligned prefix
   ([a1,b,a2] -> [a10,a20,b] double-adopted a1 while row ops retained
   a2). The window now builds from structStart, matching the ops
   builder's window exactly.
3. Optimistic applyTentative still used strict key inequality and
   first-wins matching — SameValueZero + occurrence-aware index queues
   now, parity with the plain channel (NaN-keyed and duplicate-keyed
   rows keep proxy identity through in-flight tentative reconciles).
4. A failed row-ops apply left the driver on the OLD baseline while the
   store committed the NEW topology — subsequent positional ops
   mis-indexed. The failure now flags a forced identity resync (next
   update discards given ops and rebuilds against prevRaws; slot ticks
   suppressed meanwhile). Recovery forfeits retention for that one
   apply — content correctness over node reuse — and the following
   update retains again (test proves both).
5. The throwing row's OWN partial registrations sever too (collectBind's
   finally publishes the partial collector before the throw propagates).

Size: flip-preview list tier +100 B ratchet (resync flag + severing +
entry updates ride the driver tier). Full suite 32/32.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Full-surface sweep with the auditors' method — every emission site's
capture class, every matcher's equality + occurrence + window start,
every throw point's post-exception timeline, every registration's death
paths (§21a). One real find: the shallow branch's keyAligned prefix
still used strict === — a NaN-keyed shallow slot broke alignment
(suppressing its value ticks) while SameValueZero buildRowOps emitted
nothing for aligned structure: retained row, permanently stale. The
round-1 staleness shape, in the one branch no audit had reached.

Documented as accepted (design §21a): reverted-transition stash
retention (bounded: one dead coalesced entry per record per aborted
transaction, restamped on next emission; a fix needs a core revert
hook) and keyless-row window pairing (positional adoption vs ops
remove+create — content correct, retention churn only).

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

Probe-verified: a FAILED action still commits its transition (plain
writes land, the held patch stash drains through releaseBatch at commit;
only optimistic overrides revert), and merged-away transitions hand
their stash to the survivor. Every stash drains exactly once, so the
coalescing stamps always clear — the 'aborted transaction' retention
state documented in §21a does not exist. Also corrects the misleading
'reverted transitions' comment on patchCommitHook that seeded the wrong
model.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid and others added 4 commits August 27, 2026 23:13
… pays per-adoption scans

Ryan's 'did the fixes cost our gains?' caught it: the round-2 demotion
gate ran targetIsPlain per patched-record adoption, and adoptPB resets
the scan verdict every adoption — dbmon re-probed every row's keys every
tick (~12% tick regression, strip-test attributed: 1.9 -> 1.7 ms).

Ruled by the degenerate-input principle (explicitly-odd input must never
cost correct-input prod): dev demotes AND warns loudly when a reconcile
adopts a getter-bearing object into a patched record; prod emits
directly (the dev diagnostic is the net — the string-verified prod dist
strips the whole branch, making the site byte-identical to the
strip-test build). Registration-time admission keeps its one-time scan
in both modes. Fold-commit seam same treatment.

Co-authored-by: Cursor <cursoragent@cursor.com>
…s, dispatch snapshots, init severing, active resync

1. drainFolds' no-op continue (A->B->A) now clears t.adopted — it
   permanently failed every !adopted row-op gate, freezing driven
   family lists (correct-input bug: same-batch revert is legal).
2. mergeTransitionState retargets moved entries' coalescing stamps via
   their backrefs — post-merge emissions coalesce into the moved entry
   instead of double-applying the record's patches at commit.
3. applyEntries snapshots multi-consumer lists (single-consumer pays
   nothing) and unbinds mark entries — a callback disposing a sibling
   spliced the live array mid-iteration and skipped consumers.
4. Initial list construction severs on throw like update-time builds:
   registrations under a never-mounted list kept patchCount elevated
   GLOBALLY after boundary recovery.
5. Failed-apply recovery is ACTIVE: the next slot tick resyncs (a
   value-only fix emits no row ops — suppression alone left old DOM
   indefinitely). Deep value-only recovery still waits for the next
   list event (documented).
6. Identity swaps register the new channels BEFORE applying — a
   throwing swap left both channels disconnected, stranding the list.

Size: core-floor treeshake +180B (merge move + retarget are
core-retained by necessity) and a few-dozen-byte ripple across four
scenario caps, all noted. Full suite green.

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

# Conflicts:
#	scripts/size/.size-limit.js
…d relocation)

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 8d249c7 into next Aug 28, 2026
6 of 7 checks passed
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