Skip to content

web: binding-slot fills run once, bound as client JSX binds - #3713

Merged
ryansolid merged 3 commits into
nextfrom
feat/binding-slot-execution
Sep 30, 2026
Merged

ryansolid merged 3 commits into
nextfrom
feat/binding-slot-execution

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Gap G2 in documentation/plans/examples-grid-plan.md; design in documentation/plans/binding-slot-execution.md (#3711), recorded as principles §9.2.4.

A binding slot's fill ran inside createMemo(() => fill(args)). A top-level read in its body tracked, so an eager fill re-ran on every change and disposed whatever it had created (a signal, a memo, an onCleanup) with it — while a template slot's fill ran once, untracked. The fill hackernews needs (a signal in the body, getters over it, a handler) worked only because its memo happened to track nothing.

Now a binding fill runs as a component body does:

  • Once per occurrence, untrack(() => fill(args), label) under the occurrence's owner, with live args. State in the body lives with the occurrence. A top-level read is a one-time read, and dev names it (STRICT_READ_UNTRACKED, "the `row` binding-slot fill"). Getters are the reactive form.
  • One render effect per occurrence for value positions, unchanged: assign's diff writes only what moved.
  • Handlers and refs bind once, through assign: delegation, tuples and dispatchAsInteraction, exactly as client JSX. The frames-own listener goes; several keys at one ref position still fan out. Delegated handlers are released only by the occurrence that set them.
  • Template fills run under the same labelled untrack on every path. Found on the way: only the streamed invocation was untracked; a live render (reveal content, a non-adopted mount) called the fill inside the ambient tracked computation.
  • Server: claimEntries flattens arrays at ref only. An array at a handler position (onKeyDown={[row.key, 1]}) used to drop its data silently or emit it as a second handler; it now binds nothing and raises a tuple finding pointing at the fill.

todos-server's filters fill was eager and relied on the re-run (the design's pre-check missed it); it is getters now. The hydration adoption spec's eager fill is rewritten the same way.

Public API changes

  1. An eager plain-object binding fill stops updating. A top-level read is a one-time read; dev warns STRICT_READ_UNTRACKED naming the fill. Getters are the reactive form.
  2. State created in a fill body lives as long as the occurrence (was: until the memo's next run).
  3. Binding handlers are delegated for the events client JSX delegates. A handler's e.stopPropagation() no longer stops a native listener on an ancestor, as in client JSX.
  4. Handlers and refs are read once, when an element binds. A handler behind a getter is no longer re-read per event.
  5. AttributeSlot → BindingSlot<Args, Bindings>, no alias, with the return constrained: SlotOutput<J> is J for a plain object without a $ key and a branded SlotError<reason> otherwise (array, DOM node, function, async value, reserved key), failing both where the client passes its fill and where the server reads the slot. SlotOutput and SlotError are new exports of @solidjs/web/frames.
  6. Diagnostic code ATTRIBUTE_SLOT_POSITION → BINDING_SLOT_POSITION (also in @solidjs/signals' DiagnosticCode union).
  7. Server-side handler tuples are a dev finding (reason: "tuple") instead of being flattened.
  8. Template-slot fills warn STRICT_READ_UNTRACKED on top-level reads ("the `comment` template-slot fill").
  9. Beyond the design: the fill-shape finding also names async values; a handler marker carrying several keys binds the last.

How did you test this change?

Specs first, failing before the change: a fill with a top-level signal read and a signal in its body (value frozen, state survives, one labelled warning); a getter change writing only its position (MutationObserver); delegated dispatch order against a native listener; ref fan-out vs handler last-wins; the server tuple marker and finding; the template-fill warning. test/binding-slot.type-tests.ts covers each rejected shape on both sides (verified by compiling it with the expectations stripped: each error names its reason).

Rebased onto next @ cce43eb (2026-09-30).

  • @solidjs/web: client 1110 passed (1 expected fail), server 1369 passed (2 skipped), hydrate 270 passed
  • @solidjs/signals: 4791 passed (3 expected fail, 2 skipped); solid-js: 817 passed
  • pnpm types, web test-types, and tsc --noEmit in examples/todos-server, examples/notes, examples/chat
  • Size: every scenario within its cap (after the exception below); frames eager client 12,694 B vs next's 12,696 B

Browser, examples/todos-server against the dev server (fresh Vite dep cache): hydrates with the server's checked/class; a row toggle dispatches through the delegated input handler (_$$input + its tuple data slot) and moves the row's class and the count; the filter links follow the hash (#/active, #/completed) — link class and row hidden both — through the filters fill's getters; remove hides and drops the row on refetch. Zero client warnings in dev.

Size: no frozen cap raised. Merged with next @ 3c1f809 (whose #3727 raised the page caps to 46.04 / 50.17 KB), the pages measure 45,934 / 50,125 B against that base's 46,036 / 50,166 B (-102 / -41 B brotli, -5 B minified), and the frames eager client 12,694 B against 12,696 B. The Size-Exception accepted earlier (page: live server components 50.12 -> 50.15 KB, 50,150 B against next @ cce43eb's 50,070 B) is superseded: next's cap already covers it.

@changeset-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1f40c23

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/signals Patch
@solidjs/h Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler 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 added a commit that referenced this pull request Sep 29, 2026
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

coveralls commented Sep 29, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 36759332919

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.95 hits per line

💛 - Coveralls

@github-actions

github-actions Bot commented Sep 29, 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) 9.51 KB 0 B 9.51 KB ✅
signals: + createStore 16.85 KB 0 B 16.85 KB ✅
signals: + isPending/latest 12.16 KB 0 B 12.16 KB ✅
app: render + one signal (the simple-app floor) 11.97 KB 0 B 12.05 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.66 KB 0 B 19.69 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.79 KB 0 B 30.79 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 14.90 KB 0 B 14.94 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 16.42 KB 0 B 16.48 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.65 KB 0 B 30.71 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.69 KB −2 B (−0.0%) 12.71 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 45.93 KB −102 B (−0.2%) 46.04 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 50.13 KB −41 B (−0.1%) 50.17 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.36 KB 0 B 20.36 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).

@github-actions

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) 9.51 KB 0 B 9.51 KB ✅
signals: + createStore 16.85 KB 0 B 16.85 KB ✅
signals: + isPending/latest 12.16 KB 0 B 12.16 KB ✅
app: render + one signal (the simple-app floor) 11.97 KB 0 B 12.05 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.61 KB 0 B 19.69 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.74 KB 0 B 30.74 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 14.90 KB 0 B 14.94 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 16.42 KB 0 B 16.48 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.65 KB 0 B 30.71 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.71 KB +2 B (+0.0%) 12.71 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 45.90 KB −3 B (−0.0%) 45.91 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 50.14 KB +56 B (+0.1%) 50.15 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 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).

@codspeed

codspeed Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 185 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing feat/binding-slot-execution (1f40c23) with next (3c1f809)

Open in CodSpeed

Footnotes

  1. 3 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 2 commits September 30, 2026 03:01
A binding slot's fill ran inside `createMemo(() => fill(args))`: a
top-level read tracked, so an eager fill re-ran on every change and
disposed whatever its body created with it. The template fill ran
once, untracked, under its occurrence's owner. Same slot border, two
execution models.

Now both run as a component body does. The binding fill runs once per
occurrence, `untrack(fn, label)` under the occurrence's owner with live
args: state it creates lives with the occurrence, a top-level read is
a one-time read that dev names (`STRICT_READ_UNTRACKED`, "the `row`
binding-slot fill"), and getters are the reactive form. One render
effect per occurrence still writes the value positions through
`assign`'s diff. Handlers and refs are read once when an element binds
and handed to `assign`, so events delegate, tuples bind and
interactions wrap as in client JSX; the frames-own listener goes, and
only merged refs keep a fan-out dispatcher. Template fills run under the
same labelled untrack on every path — a live render (reveal content, a
non-adopted mount) used to call them inside the ambient computation.

On the server, `claimEntries` flattens arrays at `ref` only; an array
at a handler position binds nothing and is a dev finding (reason
`tuple`) — the tuple belongs in the fill.

Renames, no aliases: `AttributeSlot` -> `BindingSlot`, whose return is
now `SlotOutput<J>` (a branded `SlotError` for an array, DOM node,
function, async value or `$` key, failing on both sides), and the
diagnostic code `ATTRIBUTE_SLOT_POSITION` -> `BINDING_SLOT_POSITION`.
The specs, principles (§9.2.4), both skills and 08-dev-diagnostics move
to the vocabulary. todos-server's `filters` fill was eager and is
getters now.

Size-Exception: the live server components page re-based 50.09 ->
50.15 KB (+56 B brotli, -5 B minified; brotli layout).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the feat/binding-slot-execution branch from 06935ab to 73640f5 Compare September 30, 2026 14:29
Conflicts in scripts/size: next's #3727 raised the page caps to 46.04 /
50.17 KB; merged with it, #3713 measures 45,934 / 50,125 B, so next's
caps stand and #3713's 50.15 KB raise is dropped. The ledger note says so.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit ecb68a1 into next Sep 30, 2026
7 checks passed
ryansolid added a commit that referenced this pull request Sep 30, 2026
next now carries #3713 as squash ecb68a1, whose tree is the
feat/binding-slot-execution tip this branch was built on, merged with
next @ 3c1f809. The merge base is the pre-#3713 cce43eb, so the
squashed changes conflicted with the same changes here: each conflicted
file resolves to this branch's version, plus next's createSSRResponse
change in packages/web/src/server.ts. The result is the tree of merging
feat/binding-slot-execution @ 1f40c23 into this branch (merge base
73640f5), where only scripts/size conflicted. The page caps are
next's here; the following commit raises them.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 30, 2026
The base moved under the PR: #3727 raised the page caps to 46.04 /
50.17 KB. Against next @ ecb68a1 (#3713 squashed) the pages measure
46,193 / 50,442 B (+259 / +317 B brotli, +760 B minified): caps 46.20 /
50.45 KB, rounded up to the next 0.01 KB. Size vetted by the maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 30, 2026
next now carries #3713 and #3714 as squashes ecb68a1 and d4b10b5.
This branch was stacked on their pre-squash commits; the only conflicts
were scripts/size, which this PR does not touch, and resolve to next's.
The result is the tree of merging feat/text-positions @ 0949bc8 into
this branch, where nothing conflicted.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid deleted the feat/binding-slot-execution branch September 30, 2026 19:47
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