Skip to content

Stage 8 Part B: live server components — frames consume live, document face, conditional reconnect, GET end to end - #3660

Merged
ryansolid merged 17 commits into
nextfrom
feat/frames-live
Sep 25, 2026
Merged

ryansolid merged 17 commits into
nextfrom
feat/frames-live

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Stage 8, Part B of documentation/plans/stage8-connection-transport.md: server components go live. A server component read through live(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 through renderToFrameStream / renderServerComponent / serverComponentResponse. A frame response also tears down on body cancel(); the frame-scope pump closes its source at disposal. SSR_STREAM_ABANDONED gains reason: "signal".

B2 — frames consume live. A live call to a server component arrives at the live address (ServerFunctionInvocation.live); frameTransformResult frames the chunk stream as server-sent events there (same headers, heartbeat and chaos knob as data). On the client the live loop'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-gated dynamic keeps its instance); completion → the loop completes. A body ending before a started frame's complete is 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 admits AsyncIterable<T> (type only). onstatus reaches 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 iterable live() 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, so dynamic(() => 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 / derived createStore over 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 — html the digest of the root's skeleton (hole and slot ranges emptied) plus a holes map; fragment / hole / attr their 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 as Last-Event-ID and the ledger as X-Frame-Have (key=digest pairs; 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 a removed list.

B6 — GET end to end. Pinned rather than built: GET(fn) on a server component dispatches over GET at the data address (the url serverFunctionUrl renders), the live address answers an event stream (curl -N), the GET() 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 carries Last-Event-ID and X-Frame-Have, and the answer is ~280 bytes (start, the composer slot, one hole — the render counter) on the same nodes, draft intact. README documents each expectation.

Not built (recorded in the plan)

  • The document-face ledger seed. The connect after adoption is a full snapshot (a morph over adopted content — still no fallback); every later reconnect is conditional. The document's hole engine numbers lh:N page-wide and pl-N keys 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-routed sc:live ops, key=digest@clientKey alias in the have-list); estimated 2–4 days, the risk being mint-order hole ids differing across faces.
  • SERVER_WRITE throw — 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:

  • New options / fields (server): renderToStream({ signal }) (and through renderToFrameStream / renderServerComponent / serverComponentResponse); FrameStreamOptions.live (a live frame response is an event stream); FrameStreamOptions.resume?: { have } (conditional render); ServerFunctionInvocation.live.
  • New API (client): Frame.have?(); createServerComponentHandler(...).resume(info) and configureServerFunctions({ responseHandler: { resume?(info) } }) → { position, headers }, consulted by the live loop per connect (the wire slot gains headers).
  • New exports: FRAME_HAVE_HEADER, FRAME_HAVE_BUDGET (= 4096) from @solidjs/web/frames client and server.
  • New registered symbol / protocol: LIVE_LOCAL (Symbol.for("solid.LiveLocal")) — the document's answer for a live call rides on the iterable live() returns.
  • Wire: live calls to server components answer at <endpoint>/live/<id> in event-stream framing; frame chunks carry digest / holes; FrameChunk gains hole and attr members; the document sc:live channel's hole/attr ops carry digest; X-Frame-Have request header; Last-Event-ID on a frame reconnect is the address's version ordinal.
  • Behavior change, frames: applyFrameResponse — a body ending before a started frame's complete is 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); an attr re-emission removes attributes absent from its text (the morph's rule — client-added attributes on an attr-hole element go with it).
  • Behavior change, dynamic: equals treats the same server-component instance as equal (no remount between placeholder and per-address binding); reads (prev, next) and delivers next's address. Source type admits AsyncIterable<T> (type-only).
  • Behavior change, server projections: createProjection / derived createStore over 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.
  • Behavior change, serverFunctionUrl: a live reference's url is its live address (was: the data address).
  • Bug fixes: signals core — the lane landing in asyncWrite calls a user equals as (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).
  • New dev-only diagnostic: SSR_UNDECLARED_LIVE_SOURCE. Diagnostic data: SSR_STREAM_ABANDONED data.reason gains "signal".
  • New @internal surface: runInServerComponentScope(fn, { live }) / inLiveServerComponentScope() on solid-js/internal; textDigest on server-functions/shared (re-exported by the server entry for the frames artifact); encodeHaveList / decodeHaveList in the frames transport; frameSkeleton on the frame-sink module (test seam); sink.attrBaseline.
  • Existing surface, new reach: onstatus on server-component references.

Withdrawn unbuilt: documentWindow on renderToStream (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) — all patch (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 -N on the live address.

Size

Measured with scripts/size (brotli, bytes) at next (7742b281c) and at the PR head; caps ratcheted in e4df9a555 (chore(size): ledger for Stage 8 Part B). Attribution from a per-module esbuild metafile of each scenario bundle at both ends.

Scenario next PR Δ Cap Attribution
signals: core floor 9,810 9,800 −10 9.82 KB (unchanged) brotli layout; core minified byte-identical but for the asyncWrite comparator argument swap
signals: + createStore 17,135 17,096 −39 17.15 KB (unchanged) same
signals: + isPending/latest 12,569 12,571 +2 12.58 KB (unchanged) same
app: render + one signal 12,655 12,619 −36 12.67 KB (unchanged) same
app: hydrating (no stores) 21,308 21,305 −3 21.32 KB (unchanged) solid.js +164 B minified (hydration compute wrapper adopts LIVE_LOCAL), absorbed by brotli
app: hydrating + stores 31,532 31,608 +76 31.55 → 31.65 KB the same solid.js +164 B minified; store engine, web byte-identical
app: CSR 15,907 15,866 −41 15.92 KB (unchanged) layout
app: CSR, observe tier 17,737 17,763 +26 17.75 → 17.80 KB 0 B minified — every retained module byte-identical; layout on a cap 13 B from full
app: CSR, observe + attribution 31,821 31,822 +1 31.84 KB (unchanged) —
frames: eager client consumer 11,651 12,367 +716 11.67 → 12.40 KB +2,343 B minified: frames client +2,275 (B2 live consumption — per-address connections with join/hold, supersession cancel, open-frame census; B3 promise-answering intercept; B4 ledger have()/#recordHave, hole/attr records, encodeHaveList/decodeHaveList, resume(), whole-text #applyAttrs), transport slice +68 (wire.headers, the per-connect resume ask)

Conscious bump on the frames consumer: the ledger and resume path hang off FrameImpl and the handler, so a consumer that never calls live still 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_SOURCE is 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/store 16.9 KB + reconcile 3.2 + projection 1.5 minified, ~10 KB brotli) whether or not a server store ever crosses the wire. The cause is frames/src/client.ts installing materializeContainerTrace (from solid-js/internal) at module load; the materializer calls solid's hydration-aware createProjection, and because solid.js is a single flat module Rollup puts everything it includes in the eager chunk. Measured on examples/room: removing the store-using /live route saves only 5 KB br (65.7 → 60.4) because the frames client retains the engine; a lazy /live saves nothing for the same reason. Not a tree-shaking failure and not fixable by a lazy import alone (the engine stays in solid.js's chunk). The fix is two-part and deferred: solid-js/@solidjs/web built preserveModules so 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 onto next twice as it moved (2d647420a #3658, then a360ad0fa #3661). Both rebases replayed clean — no conflict hunks. Semantic check on the #3658 seam: the PR adds no === CLIENT_HOLE comparison and no .then on a hole source in packages/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 new frame-live-holes-projection spec failed @solidjs/web test-types (CI's Testing & Coverage job was red on e4df9a555 for this): createProjection's public derive type does not admit Promise<AsyncIterable<T>> — promise-of-AsyncIterable flattening is a runtime posture the signals flatten-async-iterable tests also cast around. Test-only cast; no runtime or type change.
  • test(signals): the asyncWrite lane-landing comparator fix (equals(prev, next)) had no pinning test — the dynamic specs land on the plain async path, not the lane branch. Added async-lane-landing-equals-order.test.ts (async memo over a createOptimistic write inside an action); red on the old (next, prev) order, green on the fix.
  • web(frames): FRAME_HAVE_BUDGET was ledgered (plan ledger, this body, frame-conditional-reconnect changeset) as exported from the frames client and server entries, but only FRAME_HAVE_HEADER was. Exported as ledgered. 0 B on the frames size scenario.
  • docs(diagnostics): SSR_UNDECLARED_LIVE_SOURCE had its entry and repair-guide section but no row in the 08 code table; the repair guide's SSR_STREAM_ABANDONED listed consumer/sink only — "signal" added.
  • The maintainer's 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, on a360ad0fa) — signals 3726 (3725 + the new pin), solid 807 (797 + 2 #3658 + 8 #3661), web 979 / 1257 (1251 + 6 #3661) / 256 (hydrate), all test-types green, diagnostics 34, babel-plugin 268, compiler vitest 5938 (native build from the worktree), room-example build + typecheck green, frozen-lockfile install clean, no harness artifact rewrites.

Size (re-measured, scripts/size, brotli) at origin/next and 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 beyond e4df9a555's ratchet.

Review pass — surface vs the ledger. Generated .d.ts diffed against next. Beyond the ledger:

  • LIVE_WIRE is newly re-exported from @solidjs/web/server-functions/client, and createEventChunk from @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 @internal export on @solidjs/web/server-functions/server (imported by the frame sink).
  • inLiveServerComponentScope and the runInServerComponentScope(fn, { live }) option are also on the solid-js main entry (client stub, @internal) and solid-js/server, not only solid-js/internal — the same pattern as inServerComponentScope.
  • FrameChunk.attr carries removed?: 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 }) through renderToFrameStream / renderServerComponent / serverComponentResponse, FrameStreamOptions.live / resume?: { have }, ServerFunctionInvocation.live, Frame.have?(), responseHandler.resume, LIVE_LOCAL = Symbol.for("solid.LiveLocal"), dynamic's AsyncIterable<T> source type, SSR_UNDECLARED_LIVE_SOURCE in the DiagnosticCode union, SSR_STREAM_ABANDONED reason: "signal" (its data is untyped; documented), eight changesets present with the right packages, RFC 10/11 text matches the shipped serverFunctionUrl(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, its gh run rerun, and the rebased head) so it is not run-to-run noise. The PR's signals-core diff is the two-line asyncWrite comparator swap plus a type-union line; the store wrap path is byte-identical to next. Locally the bench's min/p75 are identical between next and 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-routed sc:live / key=digest@clientKey alias, 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-wrapper preserveModules split noted above.

@changeset-bot

changeset-bot Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: adfc9a4

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
solid-js Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
room-example Patch
@solidjs/universal Patch
@solidjs/compiler 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 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>
@codspeed

codspeed Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing feat/frames-live (adfc9a4) with next (a360ad0)

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 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>
@coveralls

coveralls commented Sep 25, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 36179583712

Coverage decreased (-0.09%) to 73.772%

Details

  • Coverage decreased (-0.09%) from the base build.
  • Patch coverage: 5 uncovered changes across 3 files (6 of 11 lines covered, 54.55%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/solid/src/client/hydration.ts 8 6 75.0%
packages/solid/src/internal.ts 2 0 0.0%
packages/solid/src/index.ts 1 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1183
Covered Lines: 922
Line Coverage: 77.94%
Relevant Branches: 914
Covered Branches: 625
Branch Coverage: 68.38%
Branches in Coverage %: Yes
Coverage Strength: 27.25 hits per line

💛 - Coveralls

ryansolid and others added 17 commits September 25, 2026 12:25
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
ryansolid merged commit 1d3ec5d into next Sep 25, 2026
8 checks passed
@ryansolid
ryansolid deleted the feat/frames-live branch September 25, 2026 22:56
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