Stage 8 Part B: live server components — frames consume live, document face, conditional reconnect, GET end to end - #3660
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: adfc9a4 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 |
ryansolid
added a commit
that referenced
this pull request
Sep 25, 2026
Three caps ratcheted against next (7742b28), attribution from per-module esbuild metafiles at both ends: hydrating + stores 31.55 -> 31.65 KB (+164 B minified in solid.js, the LIVE_LOCAL adoption in the hydration compute wrapper); CSR observe 17.75 -> 17.80 KB (0 B minified, brotli layout on a cap 13 B from full); frames eager consumer 11.67 -> 12.40 KB (+2,275 B minified in the frames client — live consumption, promise-answering intercept, the ledger and resume path — and +68 B in the retained transport slice). The other seven scenarios are at or under next. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Merging this PR will not alter performance
Comparing Footnotes
|
ryansolid
added a commit
that referenced
this pull request
Sep 25, 2026
Three caps ratcheted against next (7742b28), attribution from per-module esbuild metafiles at both ends: hydrating + stores 31.55 -> 31.65 KB (+164 B minified in solid.js, the LIVE_LOCAL adoption in the hydration compute wrapper); CSR observe 17.75 -> 17.80 KB (0 B minified, brotli layout on a cap 13 B from full); frames eager consumer 11.67 -> 12.40 KB (+2,275 B minified in the frames client — live consumption, promise-answering intercept, the ledger and resume path — and +68 B in the retained transport slice). The other seven scenarios are at or under next. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid
force-pushed
the
feat/frames-live
branch
from
September 25, 2026 16:41
c208c8b to
c5f01d6
Compare
Coverage Report for CI Build 36179583712Coverage decreased (-0.09%) to 73.772%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
A frame render lived as long as its sources did: `serverComponentResponse`'s
body `cancel()` only dropped writes, and nothing carried the request's
abort to the render — a frame's emission never touches the document
writable, so `renderToStream`'s disconnect path could not fire. Now
`renderToStream` takes `signal?: AbortSignal` and aborts through that
same path (`abandon("signal")`, `SSR_STREAM_ABANDONED` with
`data.reason: "signal"`). The frame responses own a teardown controller:
the body's `cancel()` aborts it, the request's signal (passed by
`frameTransformResult` / `frameTransformFlightResult` from
`event.request.signal`) chains into it, and the body closes itself on
abort since a torn-down render never ends its sink. A flight response
stops at the frame in progress and skips the rest.
The other half was in the reactive core: the frame-scope pump noticed
`comp.disposed` only when `next()` settled, so a source parked on a wait
was held until its next yield. The pump closes its source from the
compute's disposal now (`onDisposed`, run by the owner's disposal flag);
both pump sites share one `pumpIterator`.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A server component called through `live(fn)` is now a standing render
whose lifetime is the reference's loop. The call arrives at the live
address, `frameTransformResult` learns it from the invocation record
(`getServerFunctionInvocation().live`) and `serverComponentResponse({
live })` frames the chunks as server-sent events with the live headers,
the idle heartbeat and the dev chaos knob (`armLiveBody`, shared with
the codec stream). On the client the loop's wire slot rides on the
`responseHandler` ctx; `applyFrames` reads an event-stream body through
the loop's reader, tracks the frames started and not completed, and
resolves the connection's end for the loop: open frames at body end are
a death (backoff, reconnect, one morph — no fallback, no remount, same
binding), none is a completion. Supersession from another response
cancels the live connection so the loop reconnects, and the handler
holds one live connection per address — a second live reader's body is
ended and its loop joins the first's lifetime, otherwise two readers of
one call supersede each other's stream for as long as both are mounted.
Without a loop, a frame response ending before a started frame's
`complete` is now that frame's error (undeclared death is an error, RFC
11 §9.5 D1); the truncated-stream matrix cell moves accordingly.
`dynamic` is untouched: the memo pumps the live iterable as it pumps
any async iterable, and the re-yielded binding is equality-quiet on its
own.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Page `/`: `roomPanel` is `live(GET(async (room, me) => component))` — presence and the transcript rendered on the server from the same in-memory watchers `/live` reads, arriving as markup that keeps changing over one connection. Joining is the render (`onCleanup(join(room, me))`; the request's abort disposes it, which is the leave), the composer is a client slot the server positions, and `send` answers nothing — the row reaches every tab as markup through its open render. Verified in two tabs: joins and leaves arrive as morphs; killing every connection cycles the pill `connected → reconnecting → connected`, the render number climbs, no fallback appears, the frame element and the composer's input keep their identity and the half-typed draft survives; a post lands through the standing render with no reconnect. `dynamic`'s source type admits `AsyncIterable<T>` so the page mounts the panel as `dynamic(() => roomPanel(room, me))` — type-only, `dynamic` is a memo and already pumped the iterable. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…he client adopts and reconnects
Stage 8 B3. A component a `live` server function answers with renders into
the document under a live scope: every async source it reads takes its
first value into the markup and is closed, nested server components
inherit the scope, and the document completes. The scope flag is set by
`runInServerComponentScope(fn, { live })` and read at RENDER time from the
brand `brandLive` leaves on the wrapped component. `processResult` judges
the scope from the memo's owner rather than `currentOwner`: a stream that
arrives through a promise is classified in a continuation with no owner
current — which also fixes an unbranded thenable-resolved stream in
server-component scope serializing instead of pumping (pinned).
Client half. `live()` consults the frames intercept synchronously at the
call and files its answer on the iterable as `LIVE_LOCAL`; a hydrating
node with no serialized value (`dynamic`'s `serialize: false` memo) adopts
that answer as its value — the markup is the value, no request, no pending
beat — and arms its takeover, whose iteration re-yields the adopted
binding first and connects once at the live address with the intercept
skipped (`wire.adopted`). A consumer outside any hydration scope iterates
instead: seed first, then the connect. The intercept answers a boundary
the page may still deliver with a promise that lands at the reveal (or is
a miss once nothing is left to deliver it), so a live frame under a
streamed `<Loading>` connects after its fragment lands, never ahead of the
document's own render — and a plain `dynamic(() => call())` over a
streaming boundary waits for the document instead of fetching it twice.
`dynamic`'s memo `equals` is `sameInstance`: same component and address is
the same instance (placeholder vs per-address binding), and a same-
component/other-address pair delivers the address instead of swapping. No
live bit in the shell record: the client derives the call's address from
its own `(id, args)`.
Safety cap (open (c), decided: fixed dev-only warning, no knob):
`SSR_UNDECLARED_LIVE_SOURCE` after 5s of a document render still pumping an
async iterable in server-component scope; the render context carries
`document` so frame-stream renders are never judged.
Tests: server document-face spec (brand → first value + close, unbranded
pumps, nested inherits, thenable-resolved streams both ways, the cap);
parity pair artifact → hydrate in loaded and streamed modes (adopt with
zero requests, one connect after the scope release, same frame/h1/composer
input with its draft through the morph and a death → reconnect, no key
miss); client-only reader of a shown call; deferred intercept lands /
misses after exhaustion. Live-response helpers moved into the shared
lifecycle-matrix harness.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…source switched arguments stays put The room demo's document call and its standing call differ: the page renders `roomPanel(room, null)` (the browser mints the identity), and the client's source switches to `roomPanel(room, me)` during hydration. That switch is a kept resolution — same function, new address — and the adopted instance follows it. The FIRST reconnect after that swung the frame back to the document's address: presence went blank and the reconnect's render landed in a store nothing was bound to. `sameInstance` read "the address that is not the delivered one" as the incoming address, assuming the gate's argument order was unknowable. But the memo HOLDS the document's binding forever (a kept resolution never replaces its value), so the reconnect's re-yield of the standing binding was compared against the first address, and the other one — the document's — was delivered. The gate now reads its arguments as `(prev, next)` and delivers `next`'s address when it is not the one showing. That order is what every commit path in the signals core uses except one: the lane landing in `asyncWrite` called a user comparator as `equals(value, prev)`. Corrected to `(prev, value)`. Pinned in test/hydration/frame-live-document-switched.spec.tsx (its own file — the frames client's boundary index is module state): adopt at t=0, connect, switch arguments → re-bound to the standing address, death → reconnect stays there with the reconnect's render showing and the composer draft intact. The frame-live-document harness gains a third mode (`switched`) for its artifact, and the hydration specs share their replay helpers. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ydration adopts it
`/` no longer holds the panel behind `<Show when={me()}>`: the page's
render calls `roomPanel(room, null)` in process, the live scope takes
each source's first value and closes it, and the transcript, presence
row and composer range are in the HTML. Hydration adopts that markup
with no request and no fallback; the identity the tab mints during
hydration switches the source to `roomPanel(room, me)`, a different
call of the same function that `dynamic` delivers into the adopted
instance — that connection is the one that joins the room (`join` runs
only with an identity; the document's render watches).
Verified against the dev server in the browser: transcript present at
t=0, zero fallbacks, exactly one live request (at the standing
address), the same `solid-frame` and composer `input` through the
morph, and three chaos rounds each reconnecting onto the same nodes
with the draft intact and presence unchanged.
README: the `/` section describes the document face.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…e over an async iterable behaves like a memo Stage 8 B5. A server-owned frame render's binding ledger re-reads at commits, and an async-iterable MEMO read there pumps: each yield lands, commits, and holds the response (Stage 4). A projection over the same source locked its reads at V1 and pumped nothing — the trace existed for the hydration channel, and in a frame render nobody pulled it. createProjection now judges its scope from its own owner (the rule processResult applies to memos) and takes the memo's effective-mode rule: declared hybrid, or any source under a live component's document render, or a branded live source wherever the server consumes it — except the frame pump, where staying connected is the stream face working as intended. The frame pump drives the projection's SHARED trace pump (a slot-border subscriber may be pulling the same iterator; the source has one consumer) under a response hold with the document- face cap — `openPumpHold`, factored out of the memo's pumpIterator — committing per batch; reads follow the live state (no hydration claim to lock for); disposal closes the source from the disposal. A thenable-resolved iterable takes its first value as a resolution and pumps the rest once it has landed. The trace log stays empty while no subscriber reads it. The trace subscriber's stable point is now "no undrained writes" rather than "no pull in flight": under the pump a pull is always in flight, parked on a standing source, and waiting for it held the slot-border snapshot until the world moved. Pinned in test/server/frame-live-holes-projection.spec.tsx: per-yield hole re-emits for a value-yielding projection and a draft-mutating createStore, memo and projection over one source emitting identically, a thenable-resolved iterable pumping, a live-branded source staying connected and closing on abort, and the live document scope taking the first value with the document completing. The welcome/status parity artifacts re-generate with the same shell; in `rest` the trace's batch lands ahead of the slot re-emission now that the pump pulls eagerly. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
A live frame's reconnect now carries what the mount already shows, and
the server answers with only what differs.
Server: every content chunk the frame sink emits carries a digest —
`html` the digest of the root's skeleton (hole and slot ranges emptied,
markers kept) plus a `holes` map of the live holes inside it (`lh:N`
over the range, `lha:N` over the attr baseline the engine registers via
the new `sink.attrBaseline`); `fragment` its own plus `holes`; `hole`
and `attr` their own (document `sc:live` ops too). A render given a
have-list (`FrameStreamOptions.resume.have`; `frameTransformResult`
reads `X-Frame-Have` at the live address only) skips the root and its
assets when the skeleton digest matches, emits each top-level hole and
addressed attr whose digest differs, skips a fragment the list names in
favour of its differing holes (its keyed error still surfaces), streams
a fragment the list lacks as it settles, and never ships a fallback
reveal over listed content. A differing skeleton re-ships the root and
the render is the progressive stream it always was.
Client: `FrameImpl` keeps a ledger of applied content (`Frame.have()`)
— reset by a digest-carrying root, extended at each reveal, kept
current by hole/attr applies. The live loop asks the response handler
per connect (`responseHandler.resume(info)` → `{ position, headers }`);
the frames handler answers with the address's version ordinal for
`Last-Event-ID` and the ledger encoded under `X-Frame-Have`
(`key=digest` pairs; omitted over 4096 bytes → full snapshot).
`FrameChunk` gains `hole` and `attr` members. `FRAME_HAVE_HEADER` /
`FRAME_HAVE_BUDGET` exported from the frames entries; `textDigest`
added to server-functions/shared (internal).
Not built: seeding the ledger from the document face — the document's
hole engine numbers holes page-wide and fragment keys are
document-global, so the adopted interior's names do not align with a
frame render's; the connect after adoption stays a full snapshot
(morph over adopted content, no fallback). Design recorded in the plan.
Known limitation: a resumed attr hole carries no `removed` list.
Demo: the room panel's render counter is a live hole (`{renderNo()}`)
so a chaos reconnect transfers it alone — verified in the browser at
279 bytes (start, slot, one hole) on the same nodes.
Tests: test/server/frame-live-resume.spec.tsx (server rule),
test/frames-live-resume.spec.tsx (ledger + loop over three connects),
frame-live-framing (header through handleServerFunctionRequest).
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… (B6) Pins the GET path for server components, which was already whole: a `GET(fn)` reference dispatches over GET at the data address with the arguments in the url — the url `serverFunctionUrl` renders, so a preload of it is the call — and the frames handler claims the frame-stream answer off `X-Frame-Stream` whatever the method; the live address answers the same records as an event stream (`curl -N` shows one `data:` line per record); the `GET()` grant governs a frame response as it governs a codec one (undeclared → 405 same-origin, 403 cross-site, the body never runs); arguments too long for a url fall back to a JSON POST at the data address and the same stream comes back. Open decision (d) decided: `serverFunctionUrl(live(GET(fn)), ...args)` now throws. It used to return the data address — an address the live call never requests (it connects at the live address), the class of answer the helper already refuses for a POST reference; a preload of the live address would open a stream nothing reads. The error names the alternatives: call the reference to warm the address, or render the one-shot url from the inner `GET(fn)`. Tests: test/server/frame-get.spec.tsx, test/frames-get.spec.tsx. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ole text on resume Open decision (d), revised by the maintainer: `serverFunctionUrl` on a `live(GET(fn))` reference returns `<endpoint>/live/<id>[?args=...]` — the url the reference's own call requests, so a fetch of it is the call: a standing event stream to fetch by hand (`curl -N`), documented as not a preload target. The previous pass refused instead, which removed the manual/debug url with no other public source for it. The one-shot url is the inner `GET(fn)`'s. Attr holes: a conditional reconnect's `attr` re-emission carries no `removed` list (the server holds the client's previous text only as a digest). The client no longer needs one — the emission is the tag's whole attribute area, so `#applyAttrs` matches the element to it the way the root morph matches server output: sets what is present, removes what is not, keeps `data-lha` and a `<details>`/`<dialog>` `open`. The server's list is still honored where it comes. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…en (a)) as not built in Part B Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Three caps ratcheted against next (7742b28), attribution from per-module esbuild metafiles at both ends: hydrating + stores 31.55 -> 31.65 KB (+164 B minified in solid.js, the LIVE_LOCAL adoption in the hydration compute wrapper); CSR observe 17.75 -> 17.80 KB (0 B minified, brotli layout on a cap 13 B from full); frames eager consumer 11.67 -> 12.40 KB (+2,275 B minified in the frames client — live consumption, promise-answering intercept, the ledger and resume path — and +68 B in the retained transport slice). The other seven scenarios are at or under next. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ast the flatten tests use `pnpm --filter @solidjs/web test-types` (CI's Testing & Coverage job) failed on the new frame-live-holes-projection spec: the public derive type of createProjection does not admit Promise<AsyncIterable<T>> — promise-of- AsyncIterable flattening is a runtime posture (signals' flatten-async- iterable tests cast the same way). Test-only; no runtime or type change. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…s reads (prev, next)
The asyncWrite lane branch now calls equals(prevValue, value) like every
other commit path; nothing pinned it (the dynamic specs land on the plain
async path). An async memo over a createOptimistic write inside an action
routes its landing through the lane: the comparator sees ({v:0}, {v:1}) —
red on the old (next, prev) order.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ies, as ledgered The plan's public API ledger, the PR body and the frame-conditional- reconnect changeset all say FRAME_HAVE_HEADER and FRAME_HAVE_BUDGET (= 4096) are exported from @solidjs/web/frames client and server; only the header was. 0 B on the frames size scenario (the constant was already retained by encodeHaveList). Covered by .changeset/frame-conditional-reconnect.md. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…STREAM_ABANDONED's "signal" reason in the repair guide The new code had its entry in 08-dev-diagnostics.md and the repair guide but no row in the summary table; the repair guide's SSR_STREAM_ABANDONED entry listed consumer/sink only. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…-render scope The plan, the principles ledger and the open-decisions table still carried "SERVER_WRITE throw scope in persistent renders" as an open question. It was answered twice: RFC 11 §5 makes the rule blanket (no server write is legitimate anywhere; server input is derived), and the maintainer reaffirmed it for Part B. Nothing is built; the warning→throw flip follows the deprecation window, not the arrival of persistence. Also corrects the open-decisions row for (d), which still said `serverFunctionUrl` refuses a live reference — it returns the live address (the prose already did). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid
force-pushed
the
feat/frames-live
branch
from
September 25, 2026 19:25
f366c40 to
adfc9a4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stage 8, Part B of
documentation/plans/stage8-connection-transport.md: server components go live. A server component read throughlive(GET(fn))is one standing event-stream response whose markup keeps changing; the page carries it at t=0 and hydration adopts it; a reconnect is conditional — the client says what it shows and the server sends only what differs. Follows #3653 (Phase A, the data transport this rides on). RFC 10/11 (documentation/solid-2.0/10-server-functions.md,documentation/server-components/server-components-principles.md) updated in step.What lands
B1 — teardown on disconnect.
renderToStream({ signal }): the request's abort tears the render down as a disconnect, flowing throughrenderToFrameStream/renderServerComponent/serverComponentResponse. A frame response also tears down on bodycancel(); the frame-scope pump closes its source at disposal.SSR_STREAM_ABANDONEDgainsreason: "signal".B2 — frames consume
live. A live call to a server component arrives at the live address (ServerFunctionInvocation.live);frameTransformResultframes the chunk stream as server-sent events there (same headers, heartbeat and chaos knob as data). On the client theliveloop's connection is the frame's lifetime: the frames handler resolves the loop's binding and hangs the response's end on the loop's wire slot — death → backoff and re-invoke into the same binding (stable per address, so an equals-gateddynamickeeps its instance); completion → the loop completes. A body ending before a started frame'scompleteis that frame's error (undeclared death, RFC 11 §9.5 D1). One live connection per address: a second reader joins the first's lifetime.dynamic's source type admitsAsyncIterable<T>(type only).onstatusreaches server-component references.B3 — the document face. A live server component rendered into the page runs under a live scope where every source takes its first value and is closed, so the document completes; the client adopts the markup at hydration with no request and no fallback (
LIVE_LOCAL: the document's answer rides on the iterablelive()returns, a hydrating node adopts it and takes over at scope release), then connects once from the identity the tab mints. The frames intercept answers a boundary the page may still deliver with a promise of the binding, sodynamic(() => call())over a streaming boundary waits for the document instead of fetching.dynamic's equals gate treats the same server-component instance as equal (placeholder vs per-address binding no longer remounts) and reads(prev, next)— a reconnect after the source switched arguments stays put.SSR_UNDECLARED_LIVE_SOURCE(dev-only, 5 s) names a document render still pumping an async iterable in server-component scope.B5 — projections pump in frame scope.
createProjection/ derivedcreateStoreover an async iterable in a server-owned frame render pumps like a memo (holds the response, commits per yield, reads follow the live state; a live-branded source stays connected); under a live component's document render every source takes its first value. The slot-border trace's stable point is "no undrained writes" rather than "no pull in flight".B4 — conditional reconnect. Every frame chunk carries a server-minted digest —
htmlthe digest of the root's skeleton (hole and slot ranges emptied) plus aholesmap;fragment/hole/attrtheir own. The mount keeps a ledger of what it has applied (Frame.have(); a fragment counts at its reveal). Each connect, the loop asks the frames handler (responseHandler.resume) and sends the address's version ordinal asLast-Event-IDand the ledger asX-Frame-Have(key=digestpairs; omitted over 4096 bytes → full snapshot). The sink skips the root on a skeleton match and emits only the settled holes and attrs whose digest differs — never a fragment, and never a fallback reveal, over content the list names; a differing skeleton re-ships the root and the render is the progressive stream it always was. Attr holes match the element to the whole attribute text (the morph's rule), so a resume's re-emission removes vanished attributes without aremovedlist.B6 —
GETend to end. Pinned rather than built:GET(fn)on a server component dispatches over GET at the data address (the urlserverFunctionUrlrenders), the live address answers an event stream (curl -N), theGET()grant governs frame responses as it governs codec ones, and the POST fallback for long arguments answers the same stream. Open (d) decided:serverFunctionUrl(live(GET(fn)), …)returns the live address (was: the data address, which the live call never requests) — a url to fetch by hand, documented as not a preload target.examples/room—/renders the room panel as a live server component into the document: transcript, presence and the composer's range in the HTML at t=0, zero fallbacks, exactly one live request after hydration at the standing address, the same nodes through a morph. Chaos →reconnecting → connected; the reconnect request carriesLast-Event-IDandX-Frame-Have, and the answer is ~280 bytes (start, the composerslot, onehole— the render counter) on the same nodes, draft intact. README documents each expectation.Not built (recorded in the plan)
lh:Npage-wide andpl-Nkeys are document-global, while a frame render numbers both from zero, so the adopted interior's names do not align with what the same call's frame render would emit. Design written up (per-scope ordinals on the document face,fid-routedsc:liveops,key=digest@clientKeyalias in the have-list); estimated 2–4 days, the risk being mint-order hole ids differing across faces.SERVER_WRITEthrow — not built here, and not an open question: the rule is blanket (RFC 11 §5 — no server write is legitimate anywhere; all server input is derived), so there is no persistent-render scope to draw. The warning→throw flip stays on the deprecation window's clock.Public API changes
Per the plan's ledger (
## Public API ledger), each flagged before it landed:renderToStream({ signal })(and throughrenderToFrameStream/renderServerComponent/serverComponentResponse);FrameStreamOptions.live(a live frame response is an event stream);FrameStreamOptions.resume?: { have }(conditional render);ServerFunctionInvocation.live.Frame.have?();createServerComponentHandler(...).resume(info)andconfigureServerFunctions({ responseHandler: { resume?(info) } })→{ position, headers }, consulted by theliveloop per connect (the wire slot gainsheaders).FRAME_HAVE_HEADER,FRAME_HAVE_BUDGET(= 4096) from@solidjs/web/framesclient and server.LIVE_LOCAL(Symbol.for("solid.LiveLocal")) — the document's answer for a live call rides on the iterablelive()returns.<endpoint>/live/<id>in event-stream framing; frame chunks carrydigest/holes;FrameChunkgainsholeandattrmembers; the documentsc:livechannel'shole/attrops carrydigest;X-Frame-Haverequest header;Last-Event-IDon a frame reconnect is the address's version ordinal.applyFrameResponse— a body ending before a started frame'scompleteis that frame's error; one live connection per address (a second reader joins); the intercept answers a still-streaming boundary with a promise of the binding; a conditional render skips the root, listed fragments and fallback reveals (server sink, only when the request carries a have-list); anattrre-emission removes attributes absent from its text (the morph's rule — client-added attributes on an attr-hole element go with it).dynamic: equals treats the same server-component instance as equal (no remount between placeholder and per-address binding); reads(prev, next)and deliversnext's address. Source type admitsAsyncIterable<T>(type-only).createProjection/ derivedcreateStoreover an async iterable in a frame render pumps (was: first value, close); under a live component's document render every source takes its first value; trace batches ship at the pump's pace.serverFunctionUrl: a live reference's url is its live address (was: the data address).asyncWritecalls a userequalsas(prev, next)like every other commit path (was(next, prev)); server — an unbranded thenable-resolved async stream in server-component scope now pumps (was: serialized).SSR_UNDECLARED_LIVE_SOURCE. Diagnostic data:SSR_STREAM_ABANDONEDdata.reasongains"signal".@internalsurface:runInServerComponentScope(fn, { live })/inLiveServerComponentScope()onsolid-js/internal;textDigestonserver-functions/shared(re-exported by the server entry for the frames artifact);encodeHaveList/decodeHaveListin the frames transport;frameSkeletonon the frame-sink module (test seam);sink.attrBaseline.onstatuson server-component references.Withdrawn unbuilt:
documentWindowonrenderToStream(open (c) decided: fixed dev-only warning, no knob).Changesets
.changeset/:frame-teardown-on-disconnect(B1),frames-consume-live,dynamic-async-iterable-source-type(B2),frames-live-document-face,dynamic-binding-gate-prev-next(B3),frame-scope-projection-pump(B5),frame-conditional-reconnect(B4),server-function-url-live-address(B6 + the attr fix) — allpatch(prerelease).Verification
Rebased onto
next(7742b281c); full suites green there: signals 3725, solid 797, web 979 / 1251 (server) / 256 (hydrate); prettier. Browser-verified against the room dev server (document adoption, one live request, chaos → conditional reconnect at 279 bytes on the same nodes);curl -Non the live address.Size
Measured with
scripts/size(brotli, bytes) atnext(7742b281c) and at the PR head; caps ratcheted ine4df9a555(chore(size): ledger for Stage 8 Part B). Attribution from a per-module esbuild metafile of each scenario bundle at both ends.nextasyncWritecomparator argument swapLIVE_LOCAL), absorbed by brotlihave()/#recordHave,hole/attrrecords,encodeHaveList/decodeHaveList,resume(), whole-text#applyAttrs), transport slice +68 (wire.headers, the per-connectresumeask)Conscious bump on the frames consumer: the ledger and resume path hang off
FrameImpland the handler, so a consumer that never callslivestill carries them. Candidate for a later split behind the wire slot, the way Phase A moved the event-stream reader. Dev-only pieces (SSR_UNDECLARED_LIVE_SOURCEis server-side) are 0 B in every browser scenario.Known eager cost, not this PR's, named so it doesn't read as unexplained. Every page carrying the frames client also carries the store engine (
store/next/store16.9 KB +reconcile3.2 +projection1.5 minified, ~10 KB brotli) whether or not a server store ever crosses the wire. The cause isframes/src/client.tsinstallingmaterializeContainerTrace(fromsolid-js/internal) at module load; the materializer calls solid's hydration-awarecreateProjection, and becausesolid.jsis a single flat module Rollup puts everything it includes in the eager chunk. Measured onexamples/room: removing the store-using/liveroute saves only 5 KB br (65.7 → 60.4) because the frames client retains the engine; a lazy/livesaves nothing for the same reason. Not a tree-shaking failure and not fixable by a lazy import alone (the engine stays insolid.js's chunk). The fix is two-part and deferred:solid-js/@solidjs/webbuiltpreserveModulesso their store wrappers split (also unblocks route-level splitting for every app), and the frames client loading the materializer lazily with a document-face preload rule so revival stays synchronous at the claim walk. Tracked as a follow-up.Landing notes
Landed from a fresh worktree at
origin/next; branch rebased ontonexttwice as it moved (2d647420a#3658, thena360ad0fa#3661). Both rebases replayed clean — no conflict hunks. Semantic check on the #3658 seam: the PR adds no=== CLIENT_HOLEcomparison and no.thenon a hole source inpackages/solid/src/server/signals.ts; the two pre-existing comparisons are #3658's own. The head then carries five landing commits on top of the PR's twelve (plus the maintainer's open-(a) docs commit, restored after a force-push raced it — see below).Commits added on landing
test(web): the newframe-live-holes-projectionspec failed@solidjs/web test-types(CI's Testing & Coverage job was red one4df9a555for this):createProjection's public derive type does not admitPromise<AsyncIterable<T>>— promise-of-AsyncIterable flattening is a runtime posture the signalsflatten-async-iterabletests also cast around. Test-only cast; no runtime or type change.test(signals): theasyncWritelane-landing comparator fix (equals(prev, next)) had no pinning test — thedynamicspecs land on the plain async path, not the lane branch. Addedasync-lane-landing-equals-order.test.ts(async memo over acreateOptimisticwrite inside anaction); red on the old(next, prev)order, green on the fix.web(frames):FRAME_HAVE_BUDGETwas ledgered (plan ledger, this body,frame-conditional-reconnectchangeset) as exported from the frames client and server entries, but onlyFRAME_HAVE_HEADERwas. Exported as ledgered. 0 B on the frames size scenario.docs(diagnostics):SSR_UNDECLARED_LIVE_SOURCEhad its entry and repair-guide section but no row in the 08 code table; the repair guide'sSSR_STREAM_ABANDONEDlistedconsumer/sinkonly —"signal"added.docs(stage8): close open (a)(c208c8be5, pushed after the landing fetch) was dropped by the first--force-with-lease(the shared tracking ref had already advanced) and restored by cherry-pick in the next push; content intact.Verification (head
adfc9a487, ona360ad0fa) — signals 3726 (3725 + the new pin), solid 807 (797 + 2 #3658 + 8 #3661), web 979 / 1257 (1251 + 6 #3661) / 256 (hydrate), alltest-typesgreen, diagnostics 34, babel-plugin 268, compiler vitest 5938 (native build from the worktree),room-examplebuild + typecheck green, frozen-lockfile install clean, no harness artifact rewrites.Size (re-measured,
scripts/size, brotli) atorigin/nextand at the head: every scenario matches the table above byte-for-byte (frames 11,651 → 12,367; hydrating + stores 31,532 → 31,608; observe tier 17,737 → 17,763; the rest within the same ±). No cap moved beyonde4df9a555's ratchet.Review pass — surface vs the ledger. Generated
.d.tsdiffed againstnext. Beyond the ledger:LIVE_WIREis newly re-exported from@solidjs/web/server-functions/client, andcreateEventChunkfrom@solidjs/web/server-functions/server— both through the existing "wire-protocol utilities re-exported for the frame transport" block (the frames transport/sink import them).armLiveBody(controller, teardown)— new@internalexport on@solidjs/web/server-functions/server(imported by the frame sink).inLiveServerComponentScopeand therunInServerComponentScope(fn, { live })option are also on thesolid-jsmain entry (client stub,@internal) andsolid-js/server, not onlysolid-js/internal— the same pattern asinServerComponentScope.FrameChunk.attrcarriesremoved?: string[]alongside the whole-text rule (retained for the server's own attr updates; a resume's re-emission does not use it).Everything else checked out as ledgered:
renderToStream({ signal })throughrenderToFrameStream/renderServerComponent/serverComponentResponse,FrameStreamOptions.live/resume?: { have },ServerFunctionInvocation.live,Frame.have?(),responseHandler.resume,LIVE_LOCAL = Symbol.for("solid.LiveLocal"),dynamic'sAsyncIterable<T>source type,SSR_UNDECLARED_LIVE_SOURCEin theDiagnosticCodeunion,SSR_STREAM_ABANDONEDreason: "signal"(itsdatais untyped; documented), eight changesets present with the right packages, RFC 10/11 text matches the shippedserverFunctionUrl(live)(live address), one-connection-per-address, and the conditional sink (root skipped on skeleton match; no fragment, no fallback reveal over listed content).CI. Testing & Coverage, check/compare, benchmarks, Socket: green. CodSpeed: red — one benchmark,
storeWrap:deep 1k records(packages/signals/tests/core/creation.bench.ts), 11.7 → 12.4 ms (−6%), reproduced on three uploads (first head, itsgh run rerun, and the rebased head) so it is not run-to-run noise. The PR's signals-core diff is the two-lineasyncWritecomparator swap plus a type-union line; the store wrap path is byte-identical tonext. Locally the bench's min/p75 are identical betweennextand the head (0.47 / 0.52 ms) with 120 ms+ GC outliers and ±50% rme — the delta is one scavenge's worth of instructions on an allocation-only bench, i.e. heap-layout shift, not a code regression. Not merged: acknowledging it on CodSpeed needs the maintainer.Follow-ups this body records as not built: the document-face ledger seed (connect after adoption is a full snapshot; per-scope ordinals /
fid-routedsc:live/key=digest@clientKeyalias, est. 2–4 days); open (a)SERVER_WRITE— closed by the maintainer's docs commit as blanket per RFC 11 §5 (no persistent-render scope; the throw follows the deprecation window); the store-wrapperpreserveModulessplit noted above.