Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/frames-c12-fragment-error-outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"solid-js": patch
"@solidjs/web": patch
---

A server `<Loading>` inside a server component that fails after the first flush renders the server's outcome into its fragment instead of a blank (C12 (c), frames-rulings 3.3): the nearest server `<Errored>`'s fallback for the error, at the `<Loading>`'s position (asked through the boundary error handler's new `outcome` mode; a `<Loading>` between passes the question up); with no server `<Errored>` the error escapes the component — the frame's own `:error` on the stream face (an unkeyed `error` chunk), a frame-addressed `{ type: "error", fid, error }` op on the document face's `sc:live` channel (only the owning adopted boundary applies it) — and the position keeps the boundary's own fallback. `_fr` still rejects and the keyed error chunk still rides (the diagnostics). Outside a server component nothing changes (the blank the client twin renders fresh over). `HydrationContext.registerFragment`'s resolver gains a third argument (`escaped?: { frame?: string }`) and the context an internal `frameId`.
5 changes: 5 additions & 0 deletions .changeset/frames-c13-sweep-ops-chunk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

frames: one server sweep lands as one frame (C13). The sink collects the `hole` / `attr` re-emissions one sweep produces and emits them as one `{ type: "ops", ops: [...] }` chunk on the stream face (one wire line) and one `sc:live` op of the same shape on the document face; a sweep that changes one binding emits that member alone, as before. The client maps the unit to one record map and applies it as one write — one hole pass, one `frame:applied` (the hole pass now announces once per flush, not once per hole). The document op log flattens a unit into its members (last value per hole). Additive wire (`FrameChunk` gains the `ops` member; RFC addendum in `frame-streams-rfc.md`).
5 changes: 5 additions & 0 deletions .changeset/frames-plain-response-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

frames: a plain (non-`live`) server component response ends at a streaming bound. A response whose content reads a standing source — a generator memo, a projection — used to stay open until the source settled, which for a source that never returns was never, with none of `live`'s reconnect semantics. The producer now ends it after `maxYields` emitting sweeps past the first flush (default 64) or `maxDurationMs` after the first flush (default 30 000 ms) — and when the request's `signal` aborts after the first flush — emitting `{ type: "complete", bound: "yields" | "time" }` before the body closes, and tears the render down quietly (sources returned, no abandonment finding). Both are new options on `FrameStreamOptions` (`renderServerComponent`, `renderToFrameStream`, `serverComponentResponse`); a `live` response is never bounded. The client stores `:bound` beside `:complete` and, in dev, warns once per cut-off naming `live()` as the declared way past the bound. `createFrameSink` gains an optional fourth `hooks` argument (`onYield`) and its `end(bound?)` takes the bound. Additive wire; RFC addendum in `frame-streams-rfc.md`.
3 changes: 2 additions & 1 deletion documentation/plans/frames-savings-pass.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,8 @@ Each a yes/no with a recommendation.
needed. The cost is deferring the −6.4 KB page saving until Phases A and
B are in.
**Ruled 2026-10-06: yes** — S1 merges re-based at C3, after Phases A
and B.
and B. **Accepted for now; review after the first size pass**
(maintainer, 2026-10-06).
6. **The adopt-path holds (traces, regions, bind) register as pending
boundaries through `initBoundaryResume` (3.1 participants), with the
solid-side reach (`sharedConfig.resumeBoundary`, `hydrateWindow`) paid by
Expand Down
87 changes: 87 additions & 0 deletions documentation/server-components/frame-streams-rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,93 @@ frame, and that slot/slot chunks follow the same rule.
> currently only set flag keys — the readiness model re-derives everything from
> the store each flush, so there is nothing to "replay."

### Addenda (2026-10-06 — the frames correctness pass, server half)

Additive members; a producer may omit them and a consumer that predates them
reads the stream it always read (what an old consumer does with each is
stated).

**`ops` — one sweep, one unit (C13, frames-rulings §"The server half").**

```ts
| {
type: "ops";
id: string;
version: number;
ops: (
| { type: "hole"; key: string; html: string; digest?: string }
| { type: "attr"; key: string; attrs: string; removed?: string[]; digest?: string }
)[];
}
```

The server's commit unit is the sweep: one pass over every open binding,
coalesced per microtask. Before this member the wire carried a sweep's
re-emissions as N independent `hole` / `attr` chunks with no edge between
them, and the consumer — whose unit of application is the chunk — landed
them one flush apart, so a listener (a `frame:applied` handler, a
`MutationObserver`) could observe one hole of a sweep moved while a sibling
of the same sweep still showed the previous value (contract R7). The `ops`
member is the sweep's edge on the wire: the producer collects the pass's
`hole` / `attr` emissions and ships them as ONE chunk (stream face — one
wire line) or ONE `sc:live` op of the same shape (document face — the op
carries no `id` / `version`, as no document op does). Members ride
unaddressed; the envelope addresses them. A pass that changed one binding
emits that member alone, exactly as before. The consumer maps the unit to
one record map (`chunkToRecords` merges the members') and applies it as one
write — one hole pass, one `frame:applied`. Nothing is buffered, nothing is
correlated, nothing times out: a connection that dies mid-sweep dies before
the unit was written, and the unit is never half-delivered.

_Old consumer:_ `chunkToRecords` answers an unknown `type` with an empty
record map (its `default` arm), so the write lands nothing and the frame's
flush is a no-op — the sweep's values are **lost on that consumer** until a
later sweep that changes one binding at a time re-ships them (each as a
plain member), or a reconnect / refetch re-ships the root. The old consumer
does not crash and does not tear; it under-updates. The frames surface is an
experimental preview (RFC 11's status note): the member is taken as
additive on the producer and the consumer ships with it in the same
release.

**`complete.bound` — the plain response's streaming bound (savings pass §6
decision 4, ruled 2026-10-06).**

```ts
| { type: "complete"; id: string; version: number; bound?: "yields" | "time" }
```

A plain (non-`live`) server component whose content reads a standing source
— a generator memo, a projection over an async iterable — keeps its
response open and ships each later commit as holes, with no declaration of
liveness anywhere; its only end was "the source settles", which for a
source that never returns is never. The producer now ends such a response
at a bound and says so: `bound: "yields"` after `maxYields` emitting sweeps
past the first flush (default 64; a sweep that emits nothing — the source
repeating a value — is not a yield), `bound: "time"` `maxDurationMs` after
the first flush (default 30 000) **or when the request's `signal` aborts
after the first flush** (a platform deadline is a time bound the client can
tell from a death). The sink's end-of-response latch runs as for any
completion (the last sweep's values ship before the `complete`), the body
closes, and the render is torn down quietly (sources returned, holds
released; no abandonment finding — the response chose to end). Both
defaults are options on `FrameStreamOptions` (`maxYields`,
`maxDurationMs`); `0` / `Infinity` disable one. A `live` response is never
bounded: liveness IS the declaration that there is no bound, and `live()`
is the documented way past it. A `complete` with no `bound` means what it
always meant. A body that ends without any `complete` stays what it is: the
open frame's `:error` (undeclared death).

_Consumer:_ `chunkToRecords` stores `:bound` beside `:complete`; the frame
lands as on any `complete` (the covering boundary releases, `landing`
resolves), and a consumer that cares can tell a cut-off from a settled
value by the key. In dev the host warns once per response, naming `live()`.
Not surfaced as an error: the content shown is the server's last value,
which is what the frame says it is.

_Old consumer:_ reads `complete` as before (the extra field is ignored by
its `chunkToRecords`); it sees a completed frame and never learns it was a
cut-off. Degrades to today's behaviour minus the (new) distinction.

### Two identity schemes

The format uses two deliberately distinct identity schemes:
Expand Down
Loading