web: binding-slot fills run once, bound as client JSX binds - #3713
Conversation
🦋 Changeset detectedLatest commit: 1f40c23 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 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 |
Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Coverage Report for CI Build 36759332919Coverage remained the same at 75.991%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Size (brotli, eager entry chunk)
Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in |
Size (brotli, eager entry chunk)
Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in |
Merging this PR will not alter performance
Comparing Footnotes
|
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>
06935ab to
73640f5
Compare
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>
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>
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>
Summary
Gap G2 in
documentation/plans/examples-grid-plan.md; design indocumentation/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, anonCleanup) with it — while a template slot's fill ran once, untracked. The fillhackernewsneeds (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:
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.assign's diff writes only what moved.assign: delegation, tuples anddispatchAsInteraction, exactly as client JSX. The frames-own listener goes; several keys at onerefposition still fan out. Delegated handlers are released only by the occurrence that set them.untrackon 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.claimEntriesflattens arrays atrefonly. 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 atuplefinding pointing at the fill.todos-server'sfiltersfill 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
STRICT_READ_UNTRACKEDnaming the fill. Getters are the reactive form.e.stopPropagation()no longer stops a native listener on an ancestor, as in client JSX.AttributeSlot→BindingSlot<Args, Bindings>, no alias, with the return constrained:SlotOutput<J>isJfor a plain object without a$key and a brandedSlotError<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.SlotOutputandSlotErrorare new exports of@solidjs/web/frames.ATTRIBUTE_SLOT_POSITION→BINDING_SLOT_POSITION(also in@solidjs/signals'DiagnosticCodeunion).reason: "tuple") instead of being flattened.STRICT_READ_UNTRACKEDon top-level reads ("the `comment` template-slot fill").fill-shapefinding 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.tscovers 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 passedpnpm types, webtest-types, andtsc --noEmitinexamples/todos-server,examples/notes,examples/chatnext's 12,696 BBrowser,
examples/todos-serveragainst the dev server (fresh Vite dep cache): hydrates with the server'schecked/class; a row toggle dispatches through the delegatedinputhandler (_$$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 rowhiddenboth — through thefiltersfill'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 againstnext@ cce43eb's 50,070 B) is superseded:next's cap already covers it.