web: binding-slot text positions - #3714
Conversation
🦋 Changeset detectedLatest commit: 0949bc8 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>
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>
A binding-slot stand-in placed as a child is now a text position the client owns, not a markup read that renders nothing. The server emits `<!--_s:t=<occurrence>:<key>-->text<!--/_s:t-->`: the escaped t=0 value on the document face (strings and numbers only), an empty pair on the stream face. Both server walkers emit it: `resolveSSRNode` (live holes, element children) and `tryResolveString` (`renderToString`, a component's `children`). A markup-face read still raises the `markup` finding and emits an empty pair. On the client, `collectSlots` registers each pair on its parent element's existing consumer entry for the occurrence (separate entries made each treat the other's handler as released), `consumersEqual` compares the start marker, and the occurrence's one render effect writes the text node between the markers, creating it when absent. A non-primitive clears the position and raises the dev `text-shape` finding. `reconcileChildren` keeps a pair it meets again, interior and all. The `text` diagnostic reason is retired. Principles §9.2.5 records the amendment, including the raw-text rule: `<textarea>`, `<title>`, `<style>` and `<script>` content is never a text position. Size-Exception: frames eager client 12.71 -> 12.98 KB, base page 45.91 -> 46.16 KB, live page 50.15 -> 50.35 KB (+268 / +254 / +208 B brotli against #3713; +760 B minified), the text-position discovery, write and morph skip. Accepted by the maintainer. 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
0708b2a to
7a3c1be
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>
Size (brotli, eager entry chunk)
Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in |
Coverage Report for CI Build 36761159138Coverage remained the same at 75.991%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will not alter performance
Comparing Footnotes
|
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 G1 in
documentation/plans/examples-grid-plan.md; design indocumentation/plans/text-positions.md, recorded as principles §9.2.5. Was stacked on #3713, which merged as ecb68a1; retargeted tonext.A binding-slot stand-in placed as a child (
<strong>{list.remaining}</strong>) was a markup read: it rendered nothing and raised atextfinding, so a server-rendered count or label next to a handler needed a client component around the whole element. Now a child is a text position the client owns, like an attribute value:<!--_s:t=<occurrence>:<key>-->text<!--/_s:t-->. The document face holds the escaped t=0 value (strings and numbers only; nullish and booleans empty,0renders), and the stream face is empty. Both walkers emit it:resolveSSRNode(live holes, element children) andtryResolveString(renderToString, a component'schildren). Without the second, those paths dropped the value tounrecognizedInsert. A markup-face read still raisesmarkupand emits an empty pair. The writer rides on the stand-in (slotValuesets it) and the walkers only call it, so a server render with no binding slots does not retain it.collectSlotsregisters each pair on its parent element's existing consumer entry for the occurrence. When they were separate entries for one element, each treated the other's handler as released, which broke a counter button (handler, attribute and text on one<button>).consumersEqualcompares the start marker too, so a pair the morph re-creates rebinds.databetween the markers, creating it when absent. A non-primitive clears the position and raises the devtext-shapefinding.reconcileChildrenskips a matching old/new pair and keeps the old interior, so the client-owned text node survives a refetch.<textarea>,<title>,<style>and<script>content is never a text position.Two corrections to the design are marked (as built) in the plan: the two walkers, and the consumer merge.
Public API changes
textretired,text-shapeadded (BINDING_SLOT_POSITION), for a non-primitive written to a text position on the client.<!--_s:t=<occurrence>:<key>-->/<!--/_s:t-->in frames output.How did you test this change?
Specs first, including the ones pinning each load-bearing piece. Disabling the consumer merge, the morph skip, the
startcompare or thetryResolveStringbranch each fails a spec.frame-binding-slots.spec.tsx): stream-face empty pairs, document-face escaping, nullish/boolean/0, the array, function-child and component-childrenshapes under bothrenderToStringandrenderToStream, the markup finding with an empty pair.frames-binding-slots.spec.tsx): bind, a getter update and a refetch keeping the same node; another occurrence taking the position, and server release; the counter button; a morph-re-created range rebinding;text-shape.Results:
Rebased onto
next@ cce43eb (2026-09-30).@solidjs/web: client 1115 passed (1 expected fail), server 1373 passed (2 skipped), hydrate 270 passedpnpm types, webtest-typesBrowser,
examples/todos-serverwith a temporary edit (<strong>{list.remaining}</strong> {list.itemsWord} left, getters inlistFor; reverted, not in this PR), against the dev server with a fresh Vite dep cache:2inside the pair.Size-Exception: binding-slot execution / text positions; size vetted by maintainer; base moved under the PR. Against
next@ ecb68a1 (#3713 squashed; #3727 had raised the page caps to 46.04 / 50.17 KB): page: base server components 46.04 -> 46.20 KB, measured at 46,193 B against 45,934 B (+259 B brotli); page: live server components 50.17 -> 50.45 KB, measured at 50,442 B against 50,125 B (+317 B brotli); +760 B minified each. The frames eager client (not a frozen cap) is 12.71 -> 12.98 KB, measured at 12,963 B against 12,694 B (+269 B). First measured against #3713 @ 73640f5 at 12,963 / 46,204 / 50,346 B (caps 12.98 / 46.21 / 50.35 KB, accepted by the maintainer). The bytes are discovery, the text write and the morph skip. Trimmed before accepting; discovering pairs over an element's direct children was weighed and rejected (~100 B for a new rule).Size-Exception: server: renderToString 20.36 -> 20.38 KB, measured at 20,372 B against
next@ ecb68a1's 20,356 B (+16 B brotli, -35 B minified; the same numbers as against #3713 @ 73640f5). None of the text-position code is retained there (the writer rides on the stand-in); the code shrinks and brotli layout grows. Accepted by the maintainer.