Stage 2: store patch channel + patch-mode list driver (dormant by default) - #3079
Conversation
🦋 Changeset detectedLatest commit: 4135eff The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
size-limit report 📦
|
Coverage Report for CI Build 33193957269Coverage decreased (-0.6%) to 71.652%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will degrade performance by 1.59%
|
| 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)
Footnotes
-
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. ↩
b13bfcc to
1da2bbd
Compare
…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>
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>
1da2bbd to
ca63d0e
Compare
…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>
… 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>
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 apatchDriveroption (or a future default flip). Design record:packages/signals/DESIGN-PATCH-CHANNEL.md(§1–§20).@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.@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.wrapPatchMode/rowProofstamping in both Babel and Oxc, byte-identical (dom-patchparity tier, zero ratchet files);patchDrivertyped and boolean-normalized identically in both loaders.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
Test plan
for.equivalence.spec.tsx): identity modes × operation sequences, DOM content + retention topologydom-patchparity (108/108 byte-identical)Made with Cursor