Skip to content

frames: C6 — binding-slot tier; _hk on event-slot consumers closes the replay window - #3862

Merged
ryansolid merged 4 commits into
wip/frames-tiers-integrationfrom
feat/frames-bind-tier
Oct 7, 2026
Merged

ryansolid merged 4 commits into
wip/frames-tiers-integrationfrom
feat/frames-bind-tier

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Step C6 of the frames savings pass (documentation/plans/frames-savings-pass.md §3) on wip/frames-tiers-integration. Built under the maintainer's Phase 2 ruling: the incremental path continues with a frames eager target of ≤ 10 KB br; C6 proceeds; the click-replay window is closed by option (a) — the server stamps a bare _hk on event-slot consumers, the tier completes them at the bind.

Branch = wip/frames-tiers-integration @ 5c5991cbd + the cherry-pick of #3861 (5af48f58c → 6b7213d64, as instructed; it drops out of this diff when #3861 merges) + three commits: the dead-writes cleanup (732836f75), the tier (9b8ec5655), the ratchet + docs (e7d6e9e34).

Summary

  • The chunk @solidjs/web/frames/bind (frames/src/bind-tier.ts; bind.js + bind.dev.js under the development condition so the dev findings ride the dev chunk only): the _s:* marker parsers and the consumer walk (text / positions), the per-frame consumer sets and rebinders (sync / rebinder / unmount — #slotConsumers / #slotRebinders became a WeakMap the tier keeps), the morph's owned-position arms (owned / apply), the fill's binding (bind — bindDataOccurrence and its helpers; the second diff layer above assign deleted). The exports are the appliers via tierLoads.bind.r; no install. assign leaves the eager frames client.
  • The eager client keeps the detection only: hasSlotMarker at the walk — a marker met while the tier is absent is a note on the found map and the sync holds the frame under 3.1 (hydration-done waits; tierReady starts the load; the install's flush binds). Plus isAsyncValue (now in frame-client.ts, re-exported @internal from the entry — the tier's import of the entry is the edge that keeps the chunk attached to the page's graph; without it Rolldown splits the shared runtime out of the live page's entry), and the loader entry.
  • Option (a). ssrClaim / spreadBehaviorMarkers append _hk when a handler position was emitted — document face only, event positions only (a ref-only or attribute-only consumer carries nothing). bind-tier.ts's write runs completed.add(element); runHydrationEvents() once per element with handler positions. Bare: nothing gathers or claims it (the ambient sweep skips frame interiors; a prefix gather can't match the empty value), so the dev completion sweep never reports it.
  • Deviation from the "tight-nopair" variant — flagged: reconcileChildren's text-pair arm stays eager. Deleting it breaks the pin "a refetch re-sends the empty pairs: the morph keeps the client's text (and its node)" (frames-binding-slots.spec): reconciled as ordinary nodes the client's text node is removed, and since the start marker is unchanged the consumer set compares equal — no rebind, text lost. A tier dispatch point there costs what the arm costs (+109 min / +39 br measured either way), so the arm is kept with a comment.
  • Dead writes (#slotNodes.set(occurrence, consumers.map(…)) ×3) deleted in their own commit ahead of the cut — the map's only reader is the zombie check, which skips data occurrences.

Measured-before-written

Edited dist copies through scripts/size's bundler (re-attribution §7), then the real build. Old base = the branch as first briefed; new base = 6b7213d64 (wip/frames-tiers-integration @ 5c5991cbd + #3861). min / br.

scenario old base new base edited dist (tight-nopair) edited dist + (a) built head Δ built vs new base
frames: eager 40,000 / 13,083 40,012 / 13,086 −4,096 / −1,244 same eager 36,007 / 11,901 −4,005 / −1,185
page: base 119,044 / 37,772 119,056 / 37,747 −4,083 / −1,154 same eager 115,087 / 36,647 −3,969 / −1,100
page: live 130,915 / 41,427 130,927 / 41,396 −4,082 / −1,167 same eager 126,964 / 40,286 −3,963 / −1,110
non-SC (4 scenarios) — — 0 0 0 0 / 0
bind.js (lazy) — — 4,783 / 1,838 4,889 / 1,886 4,771 / 1,844 —
  • Option (a): 0 eager bytes; +106 min / +48 br in the chunk (estimate was ≈ 40 B). Wire: 4 bytes per stamped element ( _hk), document face only.
  • The built head's gap to the nopair estimate (+59 br on frames) is the kept pair arm (+39) and the isAsyncValue entry edge / isReactiveContent restored to the entry.
  • vs next @ 9d89df731: frames eager −7,407 / −1,886; page base −30,670 / −8,235; page live −30,756 / −8,309; non-SC +0 (the hydrating scenarios' +110 / +142 / +52 br vs next are the base's — the maintainer's A0 Size-Exceptions — 0 vs base). Server renderToString +4 min / −9 br vs next (within ± 10; no base reading for server scenarios).
  • Frames eager absolute: 11,901 br — 1,901 B above the ≤ 10 KB target.

Public API changes

  • New export path @solidjs/web/frames/bind (@experimental): exports text, positions, sync, rebinder, unmount, owned, apply, bind — the appliers; no install. Dist frames/dist/bind.js + bind.dev.js (development condition), types types/frames/bind-tier.d.ts. Loaded by the frames client's built-in tierLoaders.bind.
  • @solidjs/web/frames re-exports isAsyncValue (@internal) — the chunk's entry edge.
  • frame-client.ts exports SLOT_TEXT, isAsyncValue, shapeOf, slotShapeFinding as @internal for the tier (not on the public entry). SLOT_MARKER was already exported.
  • Moved behind the tier (all were module-private — nothing public moved): slotEntry, textPosition, consumersOf, slotPositions, consumersEqual, ownedPositions's body, morphOwnedClass, morphOwnedStyle, applyOwned's body, bindDataOccurrence, ElementState, handlerOwners; FrameImpl's #slotConsumers / #slotRebinders fields.
  • rollup.config.js's externalizeFramesClient regex gains bind ((trace|regions|bind)-tier); the three frames client builds list @solidjs/web/frames/bind external.

Output-shape change

  • _hk on event-slot consumers, document face. Every element the document face renders with an _s:on:* position carries a bare _hk after its markers: 4 bytes per element; ref-only and attribute-only consumers unstamped; the stream face unstamped (the bootstrap stops capturing at _$HY.done). 8 document-face expectations in server/frame-binding-slots.spec updated; a new pin fixes the scope.

Behaviour changes

  • Data occurrences mount with the CURRENT record (was: the held one, then the replacement as an args change) — their positions are written whole at the bind, so there is no claim to match the markup to. Pinned (tier-bind-hold › "a record that replaced the adopted one during the hold is the one the fill sees").
  • A data occurrence's fresh mount also waits on the regions / trace tiers when its record names them — one hold predicate for both occurrence classes (previously template occurrences only).
  • The walk no longer skips a text pair's interior (walked as ordinary nodes; the pair's own position is the tier's). No observable difference in the suites.
  • Event-slot consumers' pre-bind events queue and replay. Head-of-line: an event on a stamped consumer that never binds (no client fill for its prop) stays at the head of the queue until hydration-done drops the buffer — events queued behind it on other not-yet-completed elements wait with it. Without a fill the handler would not exist either way; flagged for the record.
  • The two halves of (a) ship together — found by the mutant: a stamp the tier does not complete is worse than none. The bootstrap keeps queueing the element's events and the delegated handler leaves a queued event to the replay (dedupEvent), so even a click AFTER the bind is lost. The first tier-bind-hold test's post-bind click pins the client half.

Pins

New test/consistency/tier-bind-hold.spec.tsx (6):

  • un-announced: a consumer in the adopted interior before the load holds the frame — positions untouched, interior on screen, hydrationInProgress() true, no mount; the install binds every position (class, checkbox, text, both handlers dispatch), hydration-done after the mount, no orphan _hk (the stamp on exactly the two event-slot consumers; page.warnings empty — the sweep listed nothing), a post-bind click dispatches.
  • replay arm (i): a click queued before hydrate() against a stamped consumer sits in _$HY.events through the hold and replays into the bound handler at the install (clicks: ["1"]).
  • replay arm (ii): a click and an input dispatched during the hold are queued at their own stamps and replay at the bind, in order; the queue is drained after.
  • the mutant — no stamp: the same page loses both arms (nothing is queued without an _hk to queue under); the bind itself is unaffected and a post-bind click dispatches.
  • a record replaced during the hold → one mount, with the replacement (the current-record mount).
  • announced (_$HY.r["sc:tiers"] names bind) → the import starts at install; the held frame binds on the install; asked no second time.

Mutant checks: the stamp-less page is the pinned test above. The client-half mutant (completed.add + runHydrationEvents disabled in the tier) fails 4 of 6 — both replay arms, and the two tests with a post-bind click (see Behaviour).

Adjusted: tier-prepare's bind arm releases the real module (the fake { install } cannot walk markers); frames-binding-slots (21) and hydration/binding-slot-adoption warm prepareTier("bind"); server/frame-binding-slots gains the stamp-scope pin (event-slot consumers only — a ref-only <span> and an attribute-only <i> unstamped; the stream face unstamped; one stamp counted) and 8 document-face expectations carry _hk; tier-announce green through the tier.

Suites: web client 129 files / 1,194 green; hydrate + consistency 87 files / 445 green; server 159 files / 1,511 green; solid 833 green; pnpm types + test-types clean. Harness 500 cases × seeds 3289 / 91501: SC arm 0 findings; generic arm (CONSISTENCY_IGNORE=C1,C9,C19,E) 0 findings.

Artifacts: re-recorded once; 1 of 150 changed — welcome-status-streamed.json. Not the stamp (no artifact carries an _s:on: element): the only server-side change since the artifact was recorded at Phase B is C3's frame-container-plugin.ts (serializer order), and the artifact is byte-stable across three re-records. The parity specs are green against it.

Size

scripts/size on this head: every scenario within its cap; check-floor-caps.mjs HEAD: no cap raised.

  • Chunk: bind.js 4,771 min / 1,844 br (both pages, lazy, not counted); bind.dev.js 11,065 raw.
  • Caps lowered (measured + 10 B at the 0.01 KB step): frames eager 13.10 → 11.92 KB (recorded minified 36,007); page base 37.79 → 36.66 (115,087); page live 41.44 → 40.30 (126,964). The unrelated caps the ratchet would also lower are left alone. No cap raised.
  • Frames eager absolute 11,901 br vs the ≤ 10 KB target: 1,901 B over. What is left eager on the frames scenario after the five tiers is the mechanism itself plus the frame core (morph, holds, store, transport) — the remaining items of the plan's list, not this step's.

Docs

  • Plan §3 row C6: the landed sentence (measured bytes, the ruling, the kept pair arm, the chunk, the Rolldown edge). §1 bind row and the "3.1 participant" definition corrected: a 3.1 hold does NOT keep the delegated-event replay window open — the probe's click during the hold was never queued (no _hk to queue under); option (a)'s stamp does. §1's "second-most visible" paragraph and §5's dependency note corrected the same way; the tier table row carries the landed chunk size.
  • Principles §9.2.3: the stamp documented in the marker-grammar notes.
  • frames-rulings 3.1 / 3.2 make no replay claim — unchanged.

Not done

  • Frames eager ≤ 10 KB br — 11,901 after C6; the remaining steps of the plan are the path, not this PR.
  • The text-pair arm of reconcileChildren stays eager (above) — the "nopair" variant as specified is not what shipped; +39 br.
  • B.3 (assign lazy on the page through dynamic) is not this step: the pages' Δ is smaller than the audit's E.c because assign never left the page.
  • #3861 rides in this diff as a cherry-pick until it merges into the integration branch.

ryansolid and others added 4 commits October 6, 2026 19:39
…`{ $tr }` record

`#syncSlots`' mounted branch guarded the update site against the regions
tier only (`needsRegions`); the trace tier's wait covered the fresh mount
alone. A MOUNTED occurrence's new record whose literal args carry a
`{ $tr }` marker while the trace tier is absent — a live slot op minting
the page's first trace after the shell, a refetch adding a projection arg —
was pushed into the live binding raw, the marker read as the value.

Now that record stays pending in the store: the live binding keeps the args
it shows, `needsTrace` starts the load (nothing announced it), and the
install's flush re-syncs with the tier in place and applies the record as
an args change into the same mount with the marker materialized. C4's
`regions` update-site wait, mirrored.

Pin: consistency/tier-trace-hold.spec "update site" (document face: a live
slot op mints the page's first trace post-shell with the tier absent → no
raw read, args kept, one load, applied on the install's flush into the same
`<li>`). Fails on the base at the first assertion (the loader never called).

Measured (exact A/B of the built dist through scripts/size's bundler, base
numbers reproduce the Phase 1 record byte-for-byte):

  frames: eager client consumer   40,012 min / 13,086 br   +12 / +3
  page: base server components   119,056 min / 37,747 br   +12 / -25
  page: live server components   130,927 min / 41,396 br   +12 / -31

Non-SC scenarios 0 / 0. No cap changed. Web client suite 1187, hydrate suite
439 green; harness seeds 3289 / 91501 ×500: SC arm 0 findings, generic arm
(IGNORE=C1,C9,C19,E) 0 findings.

Co-authored-by: Cursor <cursoragent@cursor.com>
The slot sync wrote a data occurrence's consumer elements into #slotNodes at
the mount, the rebind and the re-call. The map's only reader is the zombie
check, which skips data occurrences (a replaced consumer is a consumer
change, not a destroyed mount), so the arrays were built and never read.
Independent cleanup ahead of the bind tier (frames savings pass C6).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…e replay window

The chunk `@solidjs/web/frames/bind` (frames/src/bind-tier.ts; `bind.js` and
`bind.dev.js` under the `development` condition) takes the `_s:*` marker
parsers and the consumer walk (`text` / `positions`), the per-frame consumer
sets and rebinders (`sync` / `rebinder` / `unmount`), the morph's
owned-position arms (`owned` / `apply`) and the fill's binding (`bind`,
`assign` over every consuming element). The exports are the appliers
(`tierLoads.bind.r`); no install. `assign` leaves the eager frames client.

The eager client keeps the detection: a marker met at the walk while the
tier is absent is a note on the found map and the sync holds the frame under
frames-rulings 3.1 (hydration-done waits; the readiness check starts the
load; the install's flush binds). The hold does NOT bound the delegated-event
replay window — the bootstrap queues only under a not-yet-completed `_hk`
element and a server component's interior (NoHydration) has none. Option (a)
of the ruling closes it: the server stamps a bare ` _hk` on each DOCUMENT-face
element with an `_s:on:*` position (ssrClaim / spreadBehaviorMarkers; 4 bytes
each; ref-only and attribute-only consumers unstamped; the stream face
unstamped — the bootstrap stops capturing at `_$HY.done`); the tier marks the
element completed at the bind and drains the queue (`runHydrationEvents`).
Bare, nothing gathers or claims the stamp: the ambient sweep skips frame
interiors, a prefix gather cannot match the empty value.

Kept eager, a deviation from the "nopair" variant: `reconcileChildren`'s
text-pair arm — reconciled as ordinary nodes a client-owned text node is
removed and, the start marker unchanged, the consumer set compares equal (no
rebind; text lost — the pin "a refetch re-sends the empty pairs"). A tier
dispatch point there costs what the arm costs.

`isAsyncValue` moves to frame-client.ts and is re-exported `@internal` from
the frames entry: the tier's import of the entry is the edge that keeps the
chunk attached to the page's graph (without it Rolldown splits the shared
runtime out of the live page's entry).

Behaviour: a data occurrence mounts with the CURRENT record (its positions
are written whole at the bind — no claim to match the markup to); its fresh
mount also waits on the regions / trace tiers when its record names them;
the walk no longer skips a text pair's interior.

Pins: consistency/tier-bind-hold.spec (6 — the hold; replay arm (i) a click
queued before hydrate() replays at the install; arm (ii) a click and an
input during the hold are queued and replay at the bind; the stamp-less
mutant loses both; current-record mount; announced → import at install);
server/frame-binding-slots gains the stamp pin (event-slot consumers only,
document face only); tier-prepare's bind arm releases the real module;
frames-binding-slots and hydration/binding-slot-adoption warm the tier.
Artifacts: welcome-status-streamed re-recorded (C3's serializer drift, not
the stamp).

Size (min / br, vs the integration base 6b7213d): frames eager 36,007 /
11,901 (-4,005 / -1,185); page base 115,087 / 36,647 (-3,969 / -1,100);
page live 126,964 / 40,286 (-3,963 / -1,110); non-SC 0; `bind.js` 4,771 /
1,844 (lazy, not counted).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…40.30 KB); the hold is not the replay window

Caps set at measured + 10 B at the 0.01 KB step, lower only: frames eager
13.10 -> 11.92 KB (11,901 B; 36,007 minified), page base 37.79 -> 36.66
(36,647; 115,087), page live 41.44 -> 40.30 (40,286; 126,964). No cap raised;
the unrelated caps the ratchet would also lower are left alone.

Plan §3 row C6: the landed sentence (measured bytes, the ruling, the kept
pair arm, the chunk, the Rolldown edge). §1 bind row and the "3.1
participant" definition corrected: a 3.1 hold makes hydration-done wait but
does NOT keep the delegated-event replay window open — the C6 probe's click
during the hold was never queued (no `_hk` to queue under); option (a)'s
` _hk` stamp on event-slot consumers does. Principles §9.2.3 documents the
stamp. frames-rulings 3.1 / 3.2 make no replay claim — unchanged.

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

changeset-bot Bot commented Oct 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7d6e9e

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

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals 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

@ryansolid

Copy link
Copy Markdown
Member Author

Folded into #3860 (wip/frames-tiers-integration) per the maintainer — "merging is a bottleneck." The stack was linear, so the integration branch was fast-forwarded over this PR's three commits (732836f75, 9b8ec5655, e7d6e9e34) and its cherry-pick of #3861 (6b7213d64) to #3868's head d9395e398. Because this PR's base IS the integration branch, that plain push made its head an ancestor of its base and GitHub marked it merged on its own — nobody clicked merge; the commits reached wip/frames-tiers-integration by fast-forward, exactly as the other six folded drafts' did. The branch is kept.

On the integrated head the binding-slot tier is as this PR built it: bind.js 4,781 / 1,834 lazy (+ bind.dev.js under the development condition), assign out of the eager client, tier-bind-hold (6) green incl. both replay arms and the no-stamp mutant. Option (a) — the bare _hk on document-face _s:on:* consumers, 4 B each — is #3860's Output-shape change; the current-record mount for data occurrences is under Behaviour; the kept text-pair arm (+39 br) and the head-of-line case on a never-binding stamped consumer are its decisions 6 and 7. @solidjs/web/frames/bind and the isAsyncValue entry edge are in its Public API changes.

— Claude via Cursor

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.

1 participant