From 87127a4fd149ac6165c4da61f9a34887036ff4da Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 22:45:32 -0700 Subject: [PATCH 1/2] =?UTF-8?q?frames:=20residue=203=20=E2=80=94=20every?= =?UTF-8?q?=20fill=20through=20insert;=20createFrame=20marker-less?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every slot fill of the Solid binding is one `insert(end.parentNode, value, end, [...existing])` under the fill's owner: a static value is placed once with no effect, a reactive one binds the range as before, an adopted fill claims through `insertExpression`'s claim pass, disposal is the owner's. The frame-side copies of that lifecycle go — `normalizeSlotContent`, `isReactiveContent`, the in-place `settle` test, the no-range snapshot fallback — and so does the runtime's own range writer: `#replaceRange` and the returned-nodes path through `#syncSlots` / `#invokeSlot` / `regions.bind(…, start)`. A `Slot` callback owns its range (places or binds before `ctx.range.end` over `ctx.existing`); its return value is no longer read. The runtime's never-used comment-marker range mode (`#start` / `#end`, `#parent`, `#firstContent`, `#clearContent`) goes: the frame element IS the range; `FrameImpl(element, options)`. The second claim window of a fill (the insert) gathers nothing — the first window's gather is in the registry, and gathering again put the keys the evaluation had claimed back as unclaimed. The binding's minted owner is transparent, so a fill mounting inside the hydrate pass consumes no id from the adopting component's counter. C1 (b) re-pinned to the core's rule: a fill answering a t=0 claim with fresh nodes is a hydration mismatch — the server node stays, nothing is duplicated, hydration reports it unclaimed (it asserted the frame's replacement before). The raw-frame pins place their output through `ctx.range` themselves. frames eager 35,250 / 11,654 → 34,272 / 11,367 (−978 min / −287 br); page base −979 / −217; page live −979 / −221; non-SC scenarios 0 / 0. --- ...mes-residue-3-every-fill-through-insert.md | 5 + packages/web/frames/src/client.ts | 183 ++++++++---------- packages/web/frames/src/frame-client.ts | 178 +++++++---------- packages/web/frames/src/regions-tier.ts | 13 +- .../test/consistency/c01-claim-once.spec.tsx | 29 +-- .../call-driven-slots.spec.tsx | 14 +- packages/web/test/server/frame-hn.spec.tsx | 16 +- 7 files changed, 196 insertions(+), 242 deletions(-) create mode 100644 .changeset/frames-residue-3-every-fill-through-insert.md diff --git a/.changeset/frames-residue-3-every-fill-through-insert.md b/.changeset/frames-residue-3-every-fill-through-insert.md new file mode 100644 index 000000000..f9eb960b4 --- /dev/null +++ b/.changeset/frames-residue-3-every-fill-through-insert.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Frames client: every slot fill is placed by `insert` — the fill's output has the core's lifecycle (created under the fill's owner, claimed in place under hydration through `insertExpression`'s claim pass, disposed with the owner) instead of a frame-side copy of it. The Solid binding's static path (`normalizeSlotContent`, `isReactiveContent`, the in-place `settle` test) and the frame runtime's own range writer (`#replaceRange`) are gone; a static fill is one `insert` of a non-function value (no effect created), a reactive one binds the range as before. `createFrame` / `createFrameElement`: the frame element IS the range — the never-used comment-marker range mode of the runtime is removed. **`Slot` (`FrameOptions.slots`, `@experimental`) changes shape:** a callback owns its range — it places or binds its output before `ctx.range.end` over `ctx.existing` — and its return value is no longer read (before: returned nodes were placed by the frame; `undefined` claimed). A slot range missing its end marker (`FRAME_MARKER_CORRUPTED`) is left as the server rendered it instead of being filled at the parent's end. diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 1dcb45875..49eec8085 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -373,21 +373,6 @@ export function getFrameHost() { return sharedHost; } -/** Resolve Solid JSX slot content (thunks, arrays, primitives) to nodes. */ -function normalizeSlotContent(value: any): Node | Node[] { - while (typeof value === "function") value = value(); - if (Array.isArray(value)) { - const out: Node[] = []; - for (const v of value) { - const n = normalizeSlotContent(v); - Array.isArray(n) ? out.push(...n) : out.push(n); - } - return out; - } - if (value == null || typeof value === "boolean") return document.createTextNode(""); - return value instanceof Node ? value : document.createTextNode(String(value)); -} - /** * The stable component minted once per boundary. Every mount creates its own * frame instance under the boundary id (mounting the same server component @@ -419,8 +404,19 @@ type ClaimScope = { registry?: Map; gather?: (key: string) => vo * chain, finds its pending `_fr` registration, and resumes into the * swapped content instead of re-rendering over a fragment nobody owns. * Plain render on a page that never hydrated (CSR boot, post-load streams). + * + * `bound`: the second window of one fill — the `insert` of what the first + * evaluated. It claims under the same prefix but gathers nothing: the + * first window's gather is still in the registry, and gathering again + * would put the keys the evaluation already claimed back as unclaimed. */ -function claimRender(prefix: string, existing: Node[], render: () => any, scope?: ClaimScope) { +function claimRender( + prefix: string, + existing: Node[], + render: () => any, + scope?: ClaimScope, + bound?: boolean +) { const sc: any = sharedConfig; // No window, or no registry gathered yet (no `hydrate()` pass has run): // nothing to claim against — render fresh over the markup. @@ -429,7 +425,9 @@ function claimRender(prefix: string, existing: Node[], render: () => any, scope? sc.claimRoots = existing; try { // The claim owner too: the window claims this fill's subtree only. - return runWithOwner(createOwner({ id: prefix }), () => sc.hydrateWindow(prefix, render, scope)); + return runWithOwner(createOwner({ id: prefix }), () => + sc.hydrateWindow(bound ? undefined : prefix, render, scope) + ); } finally { sc.claimRoots = prevRoots; } @@ -573,15 +571,6 @@ function slotArgsProxy(args: () => Record) { ); } -/** Whether a resolved slot value is reactive at the top level. */ -function isReactiveContent(value: any): boolean { - if (typeof value === "function") return true; - if (Array.isArray(value)) { - for (const v of value) if (isReactiveContent(v)) return true; - } - return false; -} - /** * The slot fills of a boundary. `scope` (adopted boundaries): the * registry/gather pair the boundary adopted under, for its occurrences' @@ -594,10 +583,9 @@ function slotsFor(props: Record, scope?: ClaimScope) { // cleanup: a fill's `onCleanup` and effects live and die with the // occurrence — a later response dropping it disposes right there — not // with the covering boundary, which outlives every occurrence it covers. - // A live range binding (the reactive-content path below) is the same - // scope: the `insert` lives under the fill's owner where there is one, - // under an owner of its own otherwise, so one map disposes whichever path - // the previous invocation took. + // The range binding (the `insert` below) is the same scope: it lives + // under the fill's owner where there is one, under an owner of its own + // otherwise, so one map disposes the previous invocation whole. const fillScopes = new Map(); return new Proxy( {}, @@ -609,8 +597,8 @@ function slotsFor(props: Record, scope?: ClaimScope) { // A re-call replaces the invocation wholesale (the frame only // runs slot cleanups at unmount, not between re-calls): the // outgoing fill's scope — its binding included — disposes before - // the incoming one renders, so a static re-call after a reactive - // one never leaves a binding fighting the frame for the range. + // the incoming one renders, so two bindings never fight for the + // range. const prevFill = key !== undefined && fillScopes.get(key); if (prevFill) { fillScopes.delete(key); @@ -656,6 +644,12 @@ function slotsFor(props: Record, scope?: ClaimScope) { }); return undefined; } + // A range occurrence without its end marker has no anchor to bind + // before (the document is corrupted — `FRAME_MARKER_CORRUPTED`, + // reported at discovery): the range is left as the server + // rendered it. + const range = ctx && ctx.range; + if (!range) return undefined; // Stream-mounted fills (no ambient owner at invocation — the frame // called from a chunk microtask) render under a PER-OCCURRENCE // owner whose disposal rides the frame's occurrence-level cleanup: @@ -669,7 +663,8 @@ function slotsFor(props: Record, scope?: ClaimScope) { // computation — already owns the fill with the right lifetime (a // pending fill's nodes are legitimately detached while its // covering boundary shows the fallback; the boundary, not a frame - // cleanup, decides when that render is done with). + // cleanup, decides when that render is done with). Their range + // binding gets an owner of its own below. const fillOwner = streamInvoke ? createOwner() : null; if (fillOwner && key !== undefined && ctx) { fillScopes.set(key, fillOwner); @@ -678,20 +673,6 @@ function slotsFor(props: Record, scope?: ClaimScope) { fillOwner.dispose(); }); } - // A render whose output is already inside the range (hydration - // claims: the nodes ARE the server-rendered DOM) is a CLAIM — - // return undefined per the frame contract so nothing moves. - const settle = (out: Node | Node[]) => { - const existing: Node[] = (ctx && ctx.existing) || []; - if (existing.length) { - const list = Array.isArray(out) ? out : [out]; - const inPlace = list.every(n => - existing.some(e => e === n || (e.nodeType === 1 && (e as Element).contains(n))) - ); - if (inPlace) return undefined; - } - return out; - }; // The prop is read INSIDE the claim scope: compiled component props // are getters, so JSX evaluates lazily at access — deferring the // access into the scoped owner is what makes plain JSX (no thunks) @@ -751,66 +732,58 @@ function slotsFor(props: Record, scope?: ClaimScope) { : adopted ? claimRender(prefix, ctx.existing, evaluate, scope) : evaluate(); - // Static content (the common case: render props returning component - // roots, plain JSX with no top-level control flow): today's - // zero-cost path — claim in place or hand the frame the nodes. No - // effect is created and hydration stays a no-op. - if (!isReactiveContent(value)) { - return settle(normalizeSlotContent(value)); - } - // Reactive content (a boundary accessor, route children): snapshot- - // ting it would freeze ONE state of it into the range, so own the - // range instead — bind the value before the range's end marker with - // insert() (the same primitive compiled JSX uses for `{expr}` - // positions) and return undefined so the frame leaves the interior - // alone. `existing` seeds insert's tracked array: an accessor that - // yields the claimed nodes reconciles to a zero-mutation no-op, one - // that yields new content swaps it in place. + // Every fill is one `insert` before the range's end marker — the + // primitive compiled JSX uses for `{expr}` positions — so the + // fill's output has the core's lifecycle, not a frame-side copy of + // it: a static value (the common case — a component root, plain + // JSX) is placed once with no effect created; a reactive one (a + // boundary accessor, route children) binds the range and follows + // (a snapshot would freeze ONE state of it); an adopted fill + // claims through `insertExpression`'s claim pass — nothing moves, + // and a render whose nodes never entered the DOM is the core's + // hydration mismatch (the server's nodes stay, hydration reports + // them unclaimed; C1) — disposal is the owner's. `existing` seeds + // insert's tracked array: output that IS the claimed nodes is a + // zero-mutation no-op, a stream re-call reconciles its new output + // against the previous one. // - // The claim scope wraps the insert CALL, not the accessor: the - // binding's first evaluation is insert's own render effect computing - // synchronously, so it still creates under the producer's hydration - // keys — boundary-deferred children (route content behind - // ) create on that read — while the reads it makes belong - // to the effect and stay tracked. Claiming inside the accessor - // instead put that first read inside runWithOwner's UNTRACKED window - // (it clears `tracking` along with the owner). Whenever the value it - // returned was not itself an accessor for insert to re-read — a - // answering a still-pending streamed fragment returns its - // fallback NODES — the effect ended up with no dependency at all and - // the range went permanently inert: the boundary's own resume still - // claimed the swapped-in server markup, so the region looked right, - // but nothing downstream (a route change out of it) ever re-rendered - // it again. - if (ctx && ctx.range) { - const source = value; - // The binding's owner: the fill's own (a stream-mounted fill, - // already in `fillScopes` with its cleanup), else one minted - // here and registered the same way. - const owner = fillOwner || createOwner(); - if (!fillOwner) { - fillScopes.set(key, owner); - ctx.onCleanup(() => { - if (fillScopes.get(key) === owner) fillScopes.delete(key); - owner.dispose(); - }); - } - const end = ctx.range.end; - const bind = () => - insert( - end.parentNode as any, - () => (typeof source === "function" ? source() : source), - end, - [...ctx.existing] - ); - runWithOwner(owner, () => - adopted ? claimRender(prefix, ctx.existing, bind, scope) : bind() - ); - return undefined; + // The claim scope wraps the insert CALL, not the accessor: a + // reactive value's first evaluation is insert's own render effect + // computing synchronously, so it still creates under the + // producer's hydration keys — boundary-deferred children (route + // content behind ) create on that read — while the reads + // it makes belong to the effect and stay tracked. Claiming inside + // the accessor instead put that first read inside runWithOwner's + // UNTRACKED window (it clears `tracking` along with the owner). + // Whenever the value it returned was not itself an accessor for + // insert to re-read — a answering a still-pending + // streamed fragment returns its fallback NODES — the effect ended + // up with no dependency at all and the range went permanently + // inert: the boundary's own resume still claimed the swapped-in + // server markup, so the region looked right, but nothing + // downstream (a route change out of it) ever re-rendered it again. + // + // The binding's owner: the fill's own (a stream-mounted fill, + // already in `fillScopes` with its cleanup), else one minted here + // and registered the same way — TRANSPARENT, so a fill mounting + // inside the hydrate pass consumes no id from the adopting + // component's counter (a keyed sibling after the frame keys the + // same whether a fill mounted at t=0 or after a hold; the claim + // window below has its own id). + const owner = fillOwner || createOwner({ transparent: true }); + if (!fillOwner) { + fillScopes.set(key, owner); + ctx.onCleanup(() => { + if (fillScopes.get(key) === owner) fillScopes.delete(key); + owner.dispose(); + }); } - // No range handle (a consumer-constructed frame without markers): - // static placement is the only option — degrade to the snapshot. - return settle(normalizeSlotContent(value)); + const end = range.end; + const bind = () => insert(end.parentNode as any, value, end, [...ctx.existing]); + runWithOwner(owner, () => + adopted ? claimRender(prefix, ctx.existing, bind, scope, true) : bind() + ); + return undefined; }; } } diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index fe0ea42df..1f1334997 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -194,17 +194,17 @@ export interface SlotContext { /** * The range's current interior — server-rendered client content on an * adopted document-SSR boot, or the previous output on a re-call. A - * framework binding hydrates onto it and returns `undefined` to claim it - * in place (zero DOM mutation). + * framework binding hydrates onto it (a claim: zero DOM mutation) or + * replaces it. */ existing: ChildNode[]; /** - * The range's own marker comments, when the occurrence has a placed range. - * A framework binding whose slot content is reactive at the top level (a - * boundary accessor, changing route children) owns the interior instead of - * returning nodes: bind before `end` with the framework's insert primitive - * and return `undefined` — the frame leaves the range alone (server morphs - * already protect slot ranges). + * The range's own marker comments, when the occurrence has a placed range + * — the anchor the fill owns its interior through: bind or place the + * output before `end` (over `existing`) with the framework's insert + * primitive. The frame never touches a range's interior itself (server + * morphs protect slot ranges). Absent for a binding-slot occurrence + * (`positions`) and for a range whose end marker is missing. */ range?: { start: Comment; end: Comment }; } @@ -213,11 +213,13 @@ export interface SlotContext { * Client content for a server-declared slot. Direct-insert occurrences * call it with empty props; render-prop occurrences pass the occurrence's * resolved args (primitives literal, `{$ref}` data resolved through the - * host, `{$frame}` regions as marker-range fragments). Return nodes to fill - * the range, or `undefined` to claim `ctx.existing` untouched. + * host, `{$frame}` regions as frame elements). The fill owns its range: it + * places or binds its output before `ctx.range.end`, over `ctx.existing` + * (claimed in place on hydration), and disposes it through `ctx.onCleanup`. + * The return value is not read. * @experimental */ -export type Slot = (props: Record, ctx: SlotContext) => Node | Node[] | undefined; +export type Slot = (props: Record, ctx: SlotContext) => void; /** @experimental */ export interface Frame { @@ -426,7 +428,8 @@ export interface FrameOptions { * replay, so application is prerequisite-driven and order-independent by * construction: * - * - root HTML apply into a boundary (element or comment-marker range) + * - root HTML apply into a boundary ELEMENT (the frame's range is the + * element's children — `createFrame` / `createFrameElement`) * - version as a stale-guard only ("policy A": a newer version morphs in * place; client slots/regions and their state survive — teardown is * dispose(), never a version bump) @@ -435,7 +438,9 @@ export interface FrameOptions { * slot ranges and fragment placeholders * - the slot model: direct-insert and render-function slots as one callback * primitive, iteration by occurrence id, re-call on args change, slot - * resolution threaded down through nested frames + * resolution threaded down through nested frames; the fill OWNS its + * range (it places or binds its output before the range's end marker — + * the frame discovers ranges and invokes, it never writes an interior) * * Adaptations from the spike: * - Fragment placeholders use the document marker vocabulary emitted by @@ -1215,14 +1220,10 @@ const carriesTrace = value => const needsTrace = args => !tierLoads.trace?.r && carriesTrace(args) && !tierReady("trace"); class FrameImpl { - // A frame renders either into an element (element boundary: #start/#end - // null) or between two comment markers within some parent (range boundary). - // The parent of a range boundary is derived live from the start marker, so - // the range can be moved (e.g. re-placed by a client re-call) without - // rebinding. + // A frame renders INTO an element: the boundary / region element is the + // range (its children are the content), so it moves with the element and + // needs no markers of its own. #element; - #start; - #end; #options; // The frame this one is a region OF (`options.parent`, set by the regions // tier at bind): slot callbacks, records and record removal thread up @@ -1270,8 +1271,8 @@ class FrameImpl { // mount and the mount's rebind callback (`ctx.onRebind`) are the BIND // TIER's (`@solidjs/web/frames/bind`, plan C6), kept by that module per // frame (`sync` / `rebinder` / `unmount` off `tierLoads.bind.r`). - // Nor the mounts' output nodes: the range IS the occurrence's place - // (`#replaceRange` writes between its markers), and nothing reads the + // Nor the mounts' output: the fill owns its range (it places or binds + // before the end marker through `ctx.range`), and nothing reads the // nodes back — "mounted" is `#mountedSlots`, not a check on them. // The release of the frame's hold with the integration while a sync // leaves an occurrence waiting to mount (see #syncSlots' end). @@ -1305,10 +1306,8 @@ class FrameImpl { return scope ? scope(fn) : fn(); } - constructor(element, start, end, options = {}) { + constructor(element, options = {}) { this.#element = element; - this.#start = start; - this.#end = end; this.#options = options; this.#slots = options.slots; this.#outer = options.parent; @@ -1338,15 +1337,10 @@ class FrameImpl { if (options.adopt && this.#version === undefined) this.#syncSlots(); } - /** The node content lives in (element itself, or the range markers' parent). */ - #parent() { - return this.#element ?? this.#start.parentNode; - } - /** Server content landed: caller hook + the bubbling document notification. */ #applied(version, reason) { this.#options.onApply?.({ version, reason }); - const parent = this.#parent(); + const parent = this.#element; // Construct from the element's own realm — a cross-realm CustomEvent // (e.g. Node's global against a JSDOM document) is rejected by dispatch. const Ev = parent && (parent.ownerDocument || parent).defaultView?.CustomEvent; @@ -1360,11 +1354,6 @@ class FrameImpl { } } - /** First content node (or `#end`/null when empty). */ - #firstContent() { - return this.#start ? this.#start.nextSibling : this.#parent().firstChild; - } - get version() { return this.#version; } @@ -1760,11 +1749,11 @@ class FrameImpl { if (!mounted) { // Direct-insert occurrences have no `slot:` record and mount with // empty props; render-function occurrences mount with resolved props. - // Mounting replaces the range interior: on a fresh stream it is - // empty, but an adopted document-SSR range already holds the - // server-rendered client content — a callback that returns nodes - // replaces it (client render), one that returns undefined claims it - // in place (hydration attach; the DOM is untouched). + // The fill owns the range interior (`ctx.range`, `ctx.existing`): on + // a fresh stream it is empty, but an adopted document-SSR range + // already holds the server-rendered client content — the fill + // claims it in place (hydration attach; the DOM is untouched) or + // replaces it (client render). // In an adopt frame, a MOUNT is the hydration attach — whether the // constructor sync or a registration-flush drain (t=0 records // buffered before adoption) triggered it. ctx.adopted lets @@ -1787,26 +1776,15 @@ class FrameImpl { const held = this.#heldRecords.get(occurrence); this.#heldRecords.delete(occurrence); const mountRecord = held || record; - const nodes = this.#invokeSlot( - occurrence, - callback, - mountRecord, - start, - this.#options.adopt - ); - // A data occurrence's mount never returns nodes to place; its - // consumer set is handed to the tier (`sync`), which keeps it per - // frame for the rebind below. + this.#invokeSlot(occurrence, callback, mountRecord, start, this.#options.adopt); + // A data occurrence's consumer set is handed to the tier (`sync`), + // which keeps it per frame for the rebind below. if (consumers) B.sync(this, occurrence, consumers); - else if (nodes) this.#replaceRange(occurrence, start, nodes); this.#mountedSlots.add(occurrence); // Bind the occurrence's regions (the tier): a frame over each region - // element — those #resolveArgs minted or found, plus a re-scan of - // the fill's OUTPUT when it wrote one (`nodes`): a claim on the - // adopt path left the interior untouched, so the pre-invoke - // discovery already saw everything — the repeat walk (per - // occurrence, over a large adopted tree) is skipped. - R?.bind(this, occurrence, nodes && start); + // element #resolveArgs minted or — on the adopt path — discovered in + // the interior before the fill ran. + R?.bind(this, occurrence); if (mountRecord === record || !record || record.kind !== "slot") continue; } // A mounted data occurrence whose CONSUMERS changed — a morph replaced @@ -1854,10 +1832,9 @@ class FrameImpl { continue; } // Args changed (incl. late args): re-call this occurrence only, - // reusing its cached server-content regions. Same contract: an - // undefined return keeps the current interior. - const nodes = this.#invokeSlot(occurrence, callback, record, start); - if (!consumers && nodes) this.#replaceRange(occurrence, start, nodes); + // reusing its cached server-content regions; the fill replaces its + // previous output (`ctx.existing`) over the same range. + this.#invokeSlot(occurrence, callback, record, start); R?.bind(this, occurrence); } } @@ -1901,9 +1878,8 @@ class FrameImpl { * Invoke a slot occurrence's callback with resolved props. `ctx.existing` * carries the range's current interior (server-rendered client content on * an adopted document-SSR boot; the previous output on a re-call) so a - * framework binding can hydrate onto it. Returns the nodes to place, or - * null when the callback returned undefined — "I claimed the existing DOM, - * leave the range alone". + * framework binding can hydrate onto it, `ctx.range` the markers it + * places or binds its output within. The frame never writes an interior. */ #invokeSlot(occurrence, callback, record, start, adopted) { // A (re-)call replaces the occurrence's binding wholesale: drop the old @@ -1912,9 +1888,9 @@ class FrameImpl { this.#slotUpdaters.delete(occurrence); const cleanups = this.#slotCleanups.get(occurrence) ?? []; // One walk yields both the interior and the end marker. The end marker is - // part of the consumer contract (ctx.range): a framework binding that owns - // the range reactively (top-level dynamic slot content) needs an anchor to - // insert before — the markers are the only stable nodes in the range. + // the consumer contract (ctx.range): the fill owns the range and needs + // an anchor to insert before — the markers are the only stable nodes in + // the range. let existing = []; let end = null; // A data occurrence's node is its consumer list: no interior to collect, @@ -1948,15 +1924,15 @@ class FrameImpl { // change. Registration is per-invocation; a real re-call clears it. onUpdate: fn => this.#slotUpdaters.set(occurrence, fn), existing, - // The range's own markers, when it has them: consumers that bind the - // interior reactively insert before `end` and return undefined — the - // frame then never touches the interior (morphs protect slot ranges). + // The range's own markers, when it has them: the fill places or binds + // its output before `end` — the frame never touches the interior + // (morphs protect slot ranges). range: end ? { start, end } : undefined, // Binding slot (§9.2.3): the positions of server markup that read this // occurrence — `[{ element, positions: [{ pos, key, name }] }]` in - // document order. The consumer runs the fill, writes each position - // from its returned object, and returns undefined (there is nothing - // to place). `onRebind` receives the new set when consumers change + // document order. The consumer runs the fill and writes each position + // from its returned object (there is nothing to place). `onRebind` + // receives the new set when consumers change // (a morph replaced an element; a response bound a new position) // without the args changing — the fill's computation survives. The // rebinder is kept by the bind tier (resident: positions exist only @@ -1980,18 +1956,9 @@ class FrameImpl { // adopting render, but stream-driven mounts and re-calls arrive from // microtasks with no owner of their own — without the scope, a render // prop touching context works on boot and throws on the first refresh. - const content = this.#scoped(() => callback(props, ctx)); + this.#scoped(() => callback(props, ctx)); this.#slotArgs.set(occurrence, record); if (cleanups.length) this.#slotCleanups.set(occurrence, cleanups); - if (content == null) return null; - return Array.isArray(content) ? content : [content]; - } - - /** Replace the nodes between a slot range's start marker and its end marker. */ - #replaceRange(key, start, nodes) { - const parent = start.parentNode; - const end = eachInRange(start, key, n => parent.removeChild(n)); - for (const node of nodes) parent.insertBefore(node, end); } #unmountSlot(key) { @@ -2065,18 +2032,14 @@ class FrameImpl { /** Collect this frame's own top-level slot ranges (bounded to its content), * and — for the slot sync — its binding-slot elements into the same map. */ #collectSlots(found, elements) { - collectSlots(this.#firstContent(), this.#end, found, elements); + collectSlots(this.#element.firstChild, null, found, elements); } - /** Find a fragment placeholder `