Skip to content

Fix 3133 flatten array - #3142

Closed
nickshiro wants to merge 1444 commits into
solidjs:mainfrom
nickshiro:fix-3133-flatten-array
Closed

Fix 3133 flatten array#3142
nickshiro wants to merge 1444 commits into
solidjs:mainfrom
nickshiro:fix-3133-flatten-array

Conversation

@nickshiro

Copy link
Copy Markdown

Summary

fix bug from #3133

flattenArray overwrote the accumulated needsUnwrap flag with the reslut of each nested array. When an accessor was followed by a static nested array, later recursive call returned false, causing flatten to return raw accessor instead of a resolving wrapper. Universal renderers could consequently receive accessor function in insertNode.

Also adds regression coverage for: accessor followed by nested static array and accessor inside a nested array followed by another static fragment.

How did you test this change?

Before applying the fix, both new regression tests failed because flatten returned an array containing the raw accessor instead of a function wrapper.

After applying the fix:

  pnpm --filter @solidjs/signals test

Result:
Test Files 115 passed (115)
Tests 1431 passed | 3 skipped (1434)

also:

pnpm --filter @solidjs/signals types
pnpm exec prettier --check packages/signals/src/boundaries.ts packages/signals/tests/flatten.test.ts .changeset/fix-flatten-nested-accessor.md
git diff --check

all completed great.

ryansolid and others added 30 commits August 10, 2026 14:59
…spec, internals, transition pins. (1) SPEC-ASYNC-SEMANTICS.md gains A27: as written, A19's definition (pending ≡ observable value not final) plus exception 1 (only *uninitialized* sources are loading-class) would have classified the open loading window as pending via cause (ii) — the spec contradicted the shipped verdict-quiet behavior. A27 rules the commit-#0 window loading-class on all three axes (reads serve commit #0, transitions never initiated/extended, verdict quiet at every distance in both forms), frames it as A19's question scoping applied (answered by declaration — re-ask-shaped, A24 family) rather than a new exception, records why true was structurally unavailable (pending is chain-shaped; no held commit to shadow; a point verdict cannot propagate without rebuilding the machinery the window silences), and notes A25 unchanged for plain seeds — seedLoadingValue is the author promoting the draft to commit #0. A19 gets a one-line cross-reference. (2) INTERNALS-ASYNC-STATE.md §1 gains the _loading row (the only node field the table was missing): set at birth, invisible to the verdict path by construction, cleared by first landing on any path, kept by real errors. (3) The one untested claim in the matrix — "the window never holds a transition" lived only in comments — is now pinned by two tests: writes concurrent with an open window commit ambiently while the identical write pair after close is transition-held (the loading-class/pending-class contrast in one test), and a loadingValue node mounted inside a live transition renders commit #0 immediately and adds nothing to what the transition waits for (its never-landing flight would deadlock the completion check if the window were pending-class). Both passed first run — pins, not fixes. 27 tests green. No changeset: docs and tests only.

Co-authored-by: Cursor <cursoragent@cursor.com>
…dingValue are stripped by the hydration-aware wrappers until SSR renders commit #0. The server ignores loading values today (an async source suspends into its Loading boundary and streams the REAL value), so a node hydrating with an open loading window computes structure from the placeholder while claiming DOM the server rendered from real data — a Show over data.skeleton claims the wrong branch and corrupts the walk (pinned: the parity harness's streamed replay produced exactly this against the un-stripped build — "Hydration tag mismatch: expected <i> but found <b>item-0" — before the guard went in). The strip (stripLoadingValue, applied in hydratedCreateMemo/Signal/Optimistic and the store-like wrappers) makes a hydrating node adopt the serialized server value exactly like any async source; loading values apply only to fresh client mounts, where commit #0 is correct by construction — server HTML and hydrating client then agree by construction in both replay modes. Two parity scenarios pin it (loading-value-memo, loading-seed-store), each shaped so the placeholder flips a Show branch: a regression corrupts the claim rather than merely mismatching text, and both run in loaded and streamed modes with the harness's generic invariants (no warnings, no client-created nodes, node identity, update pass). Verified: solid-js 502/502, solid-web client 404/404, hydrate 101/101 (65 parity), server 240/241 — the one failure is retry-robustness's "root hole (no boundary)" timeout, reproduced identically on clean next (pre-existing, tracked separately). The real SSR story for the feature (server renders commit #0 into HTML, keeps streaming the landing as data, hybrid collapse) is the agreed follow-up that replaces this guard.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nder on the server and hydrate as the claimed value, replacing the interim hydration strip. The server never suspends a loading-value source: processResult serves the loading value instead of NotReadyError on every async path (thenable, iterator, hybrid, NotReady-retry from an unready sync dep, ssrSource "client"), the boundary never trips, markup flushes from commit #0, and the landing streams as data through the existing serialization channel. The first-value lock generalizes to commit #0: on serialized paths the HTML-visible value never advances past the loading value (settle callbacks skip the comp.value write; projections mark the pending proxy ready immediately, retargeted at a frozen seed copy, and the V1 freeze skips retargeting), so later-rendered holes in the same response can't tear against already-flushed placeholder markup — landings live in the data channel, exactly as later iterator yields always have. One deliberate exception to "sync results are never serialized": a sync landing after a NotReady retry ships as a resolved promise, because placeholder markup is already on the wire and the client can't re-derive its way out of DOM claimed against commit #0. createSignal's server fn-form forwards loadingValue (it previously dropped everything but deferStream/ssrSource); ServerSsrOptions gains seedLoadingValue and createProjection applies it across all four async branches. Client side, the strip (stripLoadingValue) is deleted: hydrating nodes are born committed with the loading value so the claim matches the placeholder markup by construction — and readHydratedValue gains the inverse guard the loaded replay exposed: a settled serialized landing (s===1 stamped ref) must NOT unwrap synchronously for a loading-window node, or the client computes real-data structure during the claim walk against placeholder DOM and corrupts it (the exact mirror of the pre-strip streamed failure). It hands the async runtime a clean thenable instead — commit #0 serves through the synchronous walk, the landing applies on the following microtask, the acknowledged loaded-mode tradeoff. Parity scenarios flip from pinning the strip to pinning the design: shells now assert placeholder markup (skeltail/emptyend), settled text asserts the landing, and the update pass pins post-hydration refetch. Verified: signals 1225/1225, solid-js turbo 26/27 tasks — the one failure is solid-web's pre-existing retry-robustness timeout, reproduced identically on clean next; hydrate parity 65/65 both modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
…arity matrix, two hydration fixes, commit-#0 types, and a demo page. Six new harness scenarios extend the two existing ones into the full matrix (iterator memo, ssrSource "client" memo, hybrid iterator memo, generator projection, "client" seed store, hybrid seed store — each placeholder flips a Show branch so a window disagreement corrupts the claim structurally), and the matrix immediately caught two real bugs. (1) A fully-buffered iterator replay (loaded mode) delivers its first yield synchronously via syncThenable, closing the window mid-claim — the client computed real-data structure against placeholder markup ("expected <b> but found <i>iter-final"). normalizeIterator now defers the first yield one microtask when the node has a loading window; sync delivery stays for windowless nodes, whose claim NEEDS the value. (2) The buffered store replay applied the first-yield snapshot synchronously at claim — right for windowless stores (the snapshot IS what the SSR DOM shows), wrong for a seed window (the DOM shows the SEED): after the rebase onto next picked up the backlog-parking fix (a37611e), the snapshot now parks with the backlog until hydration completes, closing the "emptyiter-bend" partial-claim corruption. The hybrid seed-store scenario also documents a pre-existing hybrid constraint: promise-shaped takeover derives hand values back by RETURNING them — draft mutations on the takeover run go to the discarded shadow draft — and with the deferred serialized adoption superseded by the takeover flight, the takeover landing is what closes the window. Types learn commit #0: loadingValue overloads on createMemo/createSignal/createOptimistic in both runtimes drop undefined from the accessor — including ssrSource "client", where the server now flushes the loading value and the client serves it pre-compute — and type prev as T, matching the signals core; the client-mode scenario drops its cast to pin the inference. The rendering example gains a Skeleton page (lazy route) demoing the basic pattern: value-channel skeleton on first flight (no Loading boundary on the page), isPending-driven dim on refetch, fresh window per client navigation; verified live against the streaming dev server — the shell flushes the skeleton markup and the post-</html> chunk is a single settle script carrying the landing as data. Verified: turbo 26/27 (the one failure is the pre-existing retry-robustness timeout on next), parity 79/79 both modes, solid-web test-types green, example typecheck green.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ateStore + seedLoadingValue renders beside the createMemo + loadingValue card, sharing one Refetch and one isPending dim (memo pending OR store-read pending), so both commit-#0 forms are visible in initial SSR and on client navigation. Verified live against the streaming dev server: the shell flushes both skeleton cards (8 skeleton lines, two aria-busy), both landings travel as serialized data only. The seedLoadingValue casts in the parity scenarios were stale, not load-bearing — ProjectionOptions (client) and ServerSsrOptions (server) both type the flag now — so all four drop; the remaining casts are the async-generator computes. solid-web test-types and example typecheck green.
…ry — no Show, no fallback tree. The loading value is now data shaped like the answer (a feed whose items haven't arrived: placeholder rows with empty text), rendered by the exact same FeedCard/For template as the landed data; CSS paints rows with the placeholder flag as shimmering blocks. This is the pattern the feature exists for — commit #0 flows through the value channel and the one template, instead of hand-rolling the branch-to-a-fake-tree shape that Loading already does better. Verified via streaming SSR: shell carries both aria-busy cards with placeholder rows, landings ride the data channel only.
… data through the real template, not a skeleton costume. Gray shimmer rows still read as a fallback; per the critique that spawned the feature ("show the UI with default data with a loading indicator", "use the real graph component as the loading skeleton but with dummy data"), the loading value is now dummy items with the real items' shape and sentence structure (Shipped release #—), rendered by the same FeedCard with normal typography, and the affordance is encoded in the data itself: a provisional flag drives dimmed text and an inline pulsing dot, nothing structural. Verified via streaming SSR: dummy items and provisional classes in the shell, the landing rides the data channel only.
…t, unconditional clears; the signals gates ratchet for the feature. The unready-source parking sequence (blocked + addPendingSource + setPendingError) dedupes into parkLoadingWindow, shared by recompute's catch (sync dependency throw) and handleAsync's handleError (NotReadyError-rejected flight); recompute's catch hoists its four instanceof NotReadyError tests into one boolean; the six window-clear landing sites drop their read guards (an unconditional store to an always-present boolean slot is smaller than check-then-write and semantically identical). The hydration entry stops precomputing hasLoadingWindow per wrapper — options thread through readSerializedOrCompute into readHydratedValue, which checks at read time (hydration-only reads, trivial property probes). Measured effect is honest but small: core floor 7.19 -> 7.18 KB brotli, minimal-app 9.84 -> 9.81; the bulk of the feature's ~110 B is ~15 already-minimal sites on always-retained memo paths, and the structural outs don't exist — the window can't ride STATUS_UNINITIALIZED (born-committed flags 0 is the invariant that keeps isPending false and transitions closed) and null-slot hooks can't shake because loadingValue is an option, not an import. Per the size-config's own convention the two breached gates ratchet with the reason recorded inline: core floor 7.1 -> 7.35 KB (measured 7.18), isPending/latest 8.75 -> 9 KB (measured 8.84); createStore (12.99/13.15), minimal (9.81/10), CSR (11.96/12) hold. scripts/size/package.json records the esbuild postinstall approval newer npm requires. Also: the demo page's aria-busy takes the enumerated "true"/"false" form and the class object coerces the provisional flag to boolean (turbo's typecheck against freshly generated types caught both). Verified: turbo 26/27 (the one failure is the pre-existing retry-robustness timeout), hydrate suite 118/118 directly, all five size gates green.
…ured on the rebased base. With the store-engine seam's new hydrating scenarios (28f7bec) now measuring the feature's hydration guards for the first time: no-store 16.15 -> 16.35 KB (measured 16.04, +210 B over the seam landing — the core window plus the claim-walk guards on the shared signal-hydration body: the clean-thenable unwrap guard, the deferred first yield, the hasLoadingWindow probe), with-stores 23.05 -> 23.3 KB (measured 22.83, same bytes plus the store-replay seed parking); reasons recorded inline per convention. Full picture against clean next: core floor +110 B, createStore +120, isPending +140, minimal +130, hydrating +210/+210, CSR +90 — all seven gates green. Verified on the rebased base: turbo 26/27 (pre-existing retry-robustness timeout only), server 245/246, hydrate 118/118.

Co-authored-by: Cursor <cursoragent@cursor.com>
The server cannot run a client source, so what the pre-compute window
renders must be the author's explicit call, never an implicit promotion
of undefined/the seed into an observable value. Signal-family sources
declare loadingValue (explicit `loadingValue: undefined` is a real
declaration — the undefined goes in the type); store-family sources
promote their seed with `seedLoadingValue: true`; effects are exempt.

Type-level: the bare "client" overloads are removed from both entries
(memo/signal/optimistic), and the store families' client options now
require `seedLoadingValue: true` (HydrationClientProjectionOptions /
ServerClientStoreOptions). Runtime: assertClientCommitZero names the
fix — behind IS_DEV on the client (zero production bytes; prod falls
back to the previous gate behavior), always-on in the single-build
server entry where the promotion would flush into markup.

Portal's internal client-sourced memos declare `loadingValue:
undefined` — "server renders nothing" is their honest commit #0. Tests
and scenarios migrate the same way; new dev-error suites pin the
message on both entries. Size gates: all seven unchanged against the
stashed baseline (measured byte-identical). Turbo 26/27 (pre-existing
retry-robustness timeout only).

Co-authored-by: Cursor <cursoragent@cursor.com>
The gate compute's synchronous prev-return counted as the node's first
real answer: recompute's sync path cleared _loading, so the
post-hydration compute — the node's actual first question — ran
pending-class (isPending true), unlike the identical source on a fresh
CSR mount, whose first flight is verdict-quiet under the window. The
ruling is that a loadingValue node's first flight never reports
pending; the gate was answering the question with commit #0 itself.

The gate now returns UNASKED — a shared never-settling thenable — for
loading-window sources. handleAsync subscribes, nothing lands, and the
existing `if (el._loading) return el._value` path serves commit #0
with no transition and no NotReadyError; the gate flip's recompute
replaces _inFlight, so the callbacks can never fire stale. Both gates:
hydrateSignalLike (memo/signal/optimistic) and hydrateStoreLikeFn
(store/projection/optimistic-store, keyed off seedLoadingValue).
Non-windowed sources keep the prev-return.

New unit tests pin the full arc for both families: commit #0 +
isPending false during hydration, still both during the first real
flight, landing commits, and the SECOND flight is pending-class like
any refetch. Cost: +41 raw bytes in hydrating bundles only (~10-20 B
brotli, gates hold with no ratchet); CSR/minimal/signals bundles are
byte-identical — the ±30 B brotli wiggle on the CSR gate is minifier
name-assignment reshuffling, verified by diffing saved bundles.
Verified: solid 513/513, hydrate harness 118/118, web 404/404 direct
(the turbo failure is the pre-existing retry-robustness timeout).

Co-authored-by: Cursor <cursoragent@cursor.com>
…tags, response holds

creationStamp() counts owner creations so the live-holes engine can latch
holes whose evaluation builds reactive scopes (impurity gate); Loading and
error boundary output accessors carry $lhSkip so boundary lifecycles are
never intercepted as re-runnable holes; the async-iterable memo pump holds
the response window open (ctx.hold) until the iterable finishes, so
iterable-fed holes stream every value. Chat demo streams markdown
token-by-token through a live innerHTML hole, with integration specs
pinning the chunk sequence.

Co-authored-by: Cursor <cursoragent@cursor.com>
…, lifetime, attr cells

Co-authored-by: Cursor <cursoragent@cursor.com>
…client pump, t=0 matrix rows

Solid half of the reactive pole's t=0 face. Server: inServerComponentScope
reads the server-component context barrier — the document face arms one
live-hole engine render-wide but only holes inside a component's scope may
mark and bind, and the iterable-memo pump takes the same gate (an iterable
in app-level NoHydration content has no live holes reading it; pumping
would hold the response for nothing).

Client: the frames consumer pumps the document's ONE sc:live record — a
module-level reader broadcasting ops to every adopted boundary at its own
address (version 0, so a call-driven stream's apply supersedes and document
ops go quiet). Page geometry routes: hole ids are document-unique and each
frame's apply searches only its own range. An op log replays to boundaries
that adopt after ops arrived — registration and replay are one synchronous
span, so the log is exactly the pre-adoption history and store idempotence
covers the overlap.

Matrix: t=0 × live holes rows — in-place morph and attr patch on adopted
content, catch-up after late adoption, supersession — plus the real-core
producer spec pinning the context-clone geometry (two components under
wrappers share one channel and the response still completes) and the
hold-latch lifetime (an iterable-fed hole's response ends at completion).

Co-authored-by: Cursor <cursoragent@cursor.com>
The assistant is already typing as the page loads: welcome() is a server
component rendered into the INITIAL document (dynamic() over the direct
call at App setup), so generation starts with the page and its markdown
streams over the document's own response. First tokens paint through the
streamed fragments before any JavaScript runs; later yields ride the
sc:live channel and the catch-up replay applies them when hydration adopts
the boundary — mid-generation, zero network. The reply() calls below it
are the same component machinery on the call-driven face, so one page
demonstrates both transports.

The greeting reuses the model driver with a faster cadence (the document
response window holds open for exactly the generation), and its text
narrates what it is demonstrating.

Co-authored-by: Cursor <cursoragent@cursor.com>
…x rows

A channel slot op (a re-emitted occurrence record from the document arg
ledger) updates the adopted occurrence's live props in place. Unlike hole
and attr ops, slot ops are store-keyed — two boundaries can share an
occurrence name — so they carry the producing frame's id and only the
owning boundary applies them. Real-core producer spec pins the natural
authored crossing (<props.status text={text()}/> over an iterable memo);
matrix rows pin the consumer half and the fid gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
The status args now cross in the natural authored form (progress={progress()},
stats={stats()} over async-value memos) instead of asyncArg: the demo shows
BOTH live representations at t=0 — markup holes (Part markdown morphing per
yield) and expression slot args (record re-emissions over sc:live), with the
per-arg pending story (stats settles at generation end, covered by the fill's
own Loading). Real-core specs pin per-arg pending and fill mint-suppression;
matrix rows added.

Co-authored-by: Cursor <cursoragent@cursor.com>
The audit found the frames client re-shipping seroval eagerly (the data
tables imported createJSONDataTable statically), defeating the lazy-codec
transports: 16.8 -> 10.9 kB gz eager, codec (13 kB) loads on first data
chunk via the host's prepareData hook. The sc:live catch-up log also
compacts per target (ops are last-value-wins) instead of growing for the
page's life. New size scenario pins the eager frames entry at 10.35 KB
with the codec external. Types pipeline reordered: the frames tsc now
reads the serialization types, so the copy step runs first.

Co-authored-by: Cursor <cursoragent@cursor.com>
Publishes the runtime's serializer decode split as
@solidjs/web/serialization/decode and points every read-only late-load at
it: the frames client's prepareData codec and deserializeStream's decode
half resolve there, so the hydration Serializer and toCrossJSONStream stay
out of browsers that never encode (~6.5 kB gz lazy chunk instead of ~13).
Rich-args encoding still loads the full serialization entry. The full
entry's surface is unchanged — it re-exports the decode module.

Co-authored-by: Cursor <cursoragent@cursor.com>
Projections passed as slot args to server components cross as bounded
async traces — one snapshot, then PatchOp batches — and materialize on
the client as live read-only projections.

Server (solid-js): projection trace registry (getProjectionTrace) over a
multi-consumer shared pump — one source iterator drives an append-only
patch log; hydration resume and every slot crossing subscribe and replay
from their own cursor, with snapshots captured only at stable pull
boundaries so undrained writes can't double-apply.

Client (solid-js): materializeContainerTrace — a projection fed by the
trace under its own root. The container REFERENCE is available
synchronously; reads INTO it suspend until the snapshot (the fill's own
<Loading> covers them, same contract as the value tier), patch batches
apply through applyPatches, the trace's end latches the last state.

Frames client (@solidjs/web): installs the materializer, revives
document-face { $tr, $ta } marker literals at arg-read (host revive),
and classifies containers FIRST, trap-safe — the slot props proxy
returns the store instead of async-probing it (a pending projection's
.then probe throws not-ready), and the record-dedupe compare
identity-tests containers through the host isContainer hook. Both gaps
were surfaced by the new lifecycle-matrix rows (container-args.spec:
stream face, shared identity, t=0 adopted markers), which pin the
client-pipeline halves end to end.

Size: frames eager consumer 10.37 KB measured (+19 B for the eager
halves — materializer install, marker reviver, WeakSet probe; the
seroval plugin itself rides the lazy codec chunk), limit 10.4 KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
…tial-stream smoothing

The demo now covers all three DR-2 tiers live: `usage` passes a
projection whole across the slot border (case 3) and <Status> reads its
materialized twin like local store state, alongside the expression-tier
progress/stats. The markdown pipeline gains the two things a real LLM UI
has — syntax highlighting (highlight.js, a server-only dep whose sole
client artifact is a CSS token palette) and a close-unfinished-constructs
pass per yield (dangling **/*/backticks balanced, half-arrived links
hidden; fences need no help — unclosed is spec-legal code-to-end).
Autoscroll moves to onSettled with a bottom-pin that respects the reader
scrolling up, and the signals canned answer shows 2.0's split
compute/effect form.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ne-projection latching

Companions to the live chat vetting: hydrating a client fallback over an
adopted region must not orphan the server's settled nodes (the
insertExpression current-honesty fix in dom-expressions), onSettled must
fire after hydration's first stable render, and a projection minted
inline in a slot-arg expression latches instead of re-emitting a fresh
trace per commit on both faces.

Co-authored-by: Cursor <cursoragent@cursor.com>
…it .js extension, untyped slot props record)

Co-authored-by: Cursor <cursoragent@cursor.com>
… {}`, compiler override drops

next.23 split the plugin's `ssr` option: it is a boolean again ("is the
app server-rendered") and the turnkey generation (entries, document
shell, serving layer) lives under `start: {}`. All four SSR examples
migrate. next.24 also pins the native compiler at next.40, so the
workspace override that held it there (the #2959 condition-memo
symmetry fix) is no longer needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
RFC 05 positions the option as an escape hatch for provisional-data UI —
Loading boundaries stay the primary pattern — and the ssrSource "client"
bullet cross-references it instead of explaining commit #0 inline.

Co-authored-by: Cursor <cursoragent@cursor.com>
…s, quiet landings (#2988 #2989 #2990)

Three holes in the loading-window contract, reported against the shipped
feature:

- #2988: a seed-window projection's derive now works a detached shadow of
  the seed — draft writes before an await or between yields land on the
  shadow and stay invisible until a commit point reconciles a detached
  snapshot into the store. The server freezes its seed copy before the
  derive runs, so SSR's commit #0 is the pure seed too.
- #2989: parking a retry on a node whose window already settled to an
  error no longer clobbers that error with a NotReadyError — the settled
  error stays the answer until a retry can actually run, on both the park
  and the source-settle paths.
- #2990: the window closes when the first answer becomes OBSERVABLE, not
  when it computes. Direct commits close it immediately; transition-held
  landings (asyncWrite holds, held sync recomputes) keep it open until
  commitPendingNode commits the hold, and the verdict's held-value branch
  is window-gated — so live observers never see a one-frame isPending
  pulse between the landing and its reveal.

Co-authored-by: Cursor <cursoragent@cursor.com>
…override drop assumed

8d9bd0a dropped the workspace override forcing @dom-expressions/compiler
to next.40 ("Drop once vite-plugin-solid bumps its pin") and bumped the
examples to next.24, but the root devDependency — the one solid-web's
vitest harness compiles with — stayed at next.21, whose native compiler
pin (next.34) predates the #2959 condition-memo symmetry fix. The
cond-component-prop parity scenario failed exactly as the removed
comment predicted: conditional component props hydrate with drifted ids
under the old compiler. next.24 pins compiler next.40; the scenario and
the full suite are green (27/27 turbo tasks).

Also commits the regenerated loading-seed-iterator-store artifact:
runtime next.41's serializer emits async generators as null-prototype
objects with Symbol.toStringTag, a benign output change the artifact
records.

Co-authored-by: Cursor <cursoragent@cursor.com>
The chat example postdates the beta.29 "stop versioning the example
apps" ignore list, so changeset version was bumping it as an internal
dependent (0.0.1-beta.0 + a dependency-only CHANGELOG).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid and others added 26 commits August 28, 2026 23:35
The scripted redirect mask exists because fetch follows redirects
before the transport can read them, so it now covers exactly the
statuses fetch follows (301/302/303/307/308, Fetch §2.2.3) — a 304,
the natural answer for a conditional read, forwards untouched for
every caller. Returned envelopes keep their status for unscripted
callers (the returned path hardcoded 200 where the thrown path
forwarded it), and the no-JS form convention honors a returned
redirect envelope's Location the way it already honored a thrown one.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Mutations feeding live sources (sockets, subscriptions, live queries) have
no response to await; until(fn) resolves when a reactive predicate settles
truthy, so `yield until(...)` holds the action's transaction — and its
optimistic state — open until the world confirms. Predicates read the
authoritative view: the caller's own optimistic overrides are invisible
(no self-ack), while transition-staged truth reads normally (refusing it
would deadlock the hold on its own data plane). Timeout/abort reject at
the yield point, reverting like any failed action.

Also fixes resolve()/until() stale delivery when a source settles into a
held transaction: promise-delivery effects now commit their value directly
(CONFIG_DIRECT_COMMIT), keeping value and delivery on one schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
The client classified any status >= 500 as failure, so respond(value,
{ status: 500 }) threw a synthesized error with the author's value
discarded. Among responses the runtime encoded (body format present)
the status is the author's data channel: only the protocol's error tag
rejects now, and a peer's own 5xx (no body format) is still refused
before decoding. The server agrees from its side: a plain thrown error
answers a real 500 instead of 200-with-tag, so intermediaries see what
the tag tells the client.

Co-authored-by: Cursor <cursoragent@cursor.com>
…#3094)

One url served two answer shapes — codec encodings for the client
transport (keyed on the instance header), plain HTTP for everyone else —
and shared caches key on the url alone, so one caller kind's cached
answer could be replayed to the other. Scripted calls now go to
<endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a
reference's .url, rendered form actions, direct callers). The shape is
a function of the url, never a header. Transitional: the instance
header still summons the scripted shape at the bare address so loaded
tabs survive a deploy, with those answers forced no-store.

Co-authored-by: Cursor <cursoragent@cursor.com>
…#3100)

The reconnect loop drew the definite-rejection line at "below 500", so
a rate limiter's 429 or a gateway's 408 permanently closed a healthy
stream — the statuses most likely to be transient, and least likely to
be the application's own answer. 408/425/429 now reconnect like a 5xx,
as does any failure carrying Retry-After: the peer naming the wait has
answered the question the exponential backoff guesses at, so the named
wait (capped at 60s) replaces the guess for that attempt, and the
header value rides the surfaced error as `retryAfter` (seconds) for
policy layers.

Co-authored-by: Cursor <cursoragent@cursor.com>
The live() retry commit accidentally included unrelated, uncommitted
quiescence work from a concurrent session (packages/signals core,
solid server signals, treeshake test). This restores those files to
their prior committed state; the in-progress work returns to the
working tree, uncommitted, where it was.

Co-authored-by: Cursor <cursoragent@cursor.com>
)

Production ids were `<xxhash32(path)>-<ordinal>`, so appending a server
function to a file renumbered the rest and clients holding the old
numbering silently dispatched to different code with a 200. Ids are now
`<name>-<xxhash32(root-relative path)>`, with a trailing ordinal only
when the same descriptive name recurs in one file: appends, deletes,
reorders, and body edits move no address, and a removed or renamed
function becomes a clean 404 instead of a wrong call. Development and
production now share the exact same id format.

Frozen directive fixtures regenerated deliberately per their README; the
diff is id literals only.

Co-authored-by: Cursor <cursoragent@cursor.com>
…3110)

A call whose well-formed address is not registered in the answering
deployment is the ordinary consequence of deploying under open tabs, and
until now it surfaced as a bare 404 nothing could act on. The server now
labels it with X-Server-Function-Unknown, and the client stamps
unknownFunction: true (plus a directed message) on the rejection, so an
integration can recover — reload the document onto the current build —
instead of showing a generic failed call. Meaningless-path 404s stay
unlabelled: a mistyped route is not skew.

Co-authored-by: Cursor <cursoragent@cursor.com>
…hims (#3102, #3107)

Scripted callers now receive masked redirects as
X-Server-Function-Redirect: <status> <url>, the target resolved
server-side against the request url — the meaning HTTP assigns the
Location a form post would have received. Location never rides a masked
200 (no HTTP meaning there, and it collided with authored Locations on
forwarding statuses like a 201's created-at), and integrations compare
origins on a real url instead of guessing navigation strategy from the
author's spelling — the #3107 relative/absolute coin toss removed at the
source. decodeRedirectHeaderValue is exported for readers; a real 3xx
reaching the transport (a peer that opted out of following) still passes
through whole.

Also retires the RC transition shims per the breaking-address release:
the instance-header scripted fallback at the bare address and its forced
no-store (#3094) are gone — the answer shape is a function of the url
alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
A derived optimistic store's source draft composed the caller's optimistic
overlay in post-await continuations: a generator's store.push read length
through an action's optimistic row and landed truth at the wrong index,
permanently committing [null, row]. Serve-side overlay gates (values,
length, membership, keys, descriptors) now also admit the
authoritative-write posture — the same pair ensurePB already used to seed
authoritative drafts — so truth authors never read tentative state. User
setter drafts keep composing on the optimistic view (#2951); write-side
machinery (patch emission, tentative rebasing) keeps composing explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
Awaiting refresh(x) gives imperative flows and action steps the settle
point without a reactive read: accessor targets deliver the settled value,
store targets deliver the node passed, failures reject at the yield, and
supersession folds every waiter onto whatever finally lands. Staged
landings under a held transaction deliver (resolve/until parity, #2930)
and never the caller's own optimistic override; bare refresh stays
verdict-quiet and fire-and-forget callers never see an unhandled
rejection.

The waiter is resolve()'s effect machinery plus one reader bit
(CONFIG_FRESH_READ) that pulls a still-dirty source through recompute
inline, deferred a microtask so same-tick refreshes still coalesce into
one re-ask. Costs 694B min / 256B gz to refresh importers only; the core
floor stays under its guard, paid by nullish golfs. updateIfNecessary now
also refuses disposed nodes (#2983's class).

Co-authored-by: Cursor <cursoragent@cursor.com>
The scripted transport sends no conditional headers, so a hand-rolled
respond(undefined, { status: 304 }) answers a question the caller never
asked: the bodiless answer resolves the call to undefined and consumer
state clears, reading as data loss. The 304 still forwards untouched —
it is real HTTP for unscripted callers and the natural conditional-read
answer — but the dev build now names the function and points at the
supported shape: GET-declared reads with ETag/Cache-Control, where the
browser owns the conditional exchange and replays its cached 200.

Also scrubs RFC 10's mention of the retired instance-header transitional
courtesy.

Co-authored-by: Cursor <cursoragent@cursor.com>
…cy raw payload

The lone-unnamed-fold special case existed to stay byte-identical with
peers predating named sources; with the RC already breaking the wire,
it leaves with the other shims. The envelope is always keyed by source
id — the unnamed registration rides under its reserved id "true" like
any other — and the client always delivers data[source] to each
consumer. The unrecognized-opt-in courtesy goes with it: an id naming
no registered hook folds nothing, instead of falling back to the
unnamed hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
The capture runs inside the effect's own compute, where the ambient
owner IS the effect — a Computed, which is exactly what dispose()
takes. Typing it Owner failed declaration emit (TS2345) and took
Solid CI, Size, and CodSpeed down with it. Type-only; no runtime
change.

Co-authored-by: Cursor <cursoragent@cursor.com>
The #3108 truth-author authoritative-read fix (88fa9d6) and the
refresh() quiescence promise (51ffcb9) land on always-retained
optimistic/settle-walk paths, so the signals-carrying scenarios drifted
9-69 B past their caps (isPending/latest +16 B, simple-app floor +13 B,
full-store hydration +69 B, patchDriver flip +9 B). The web/frames
scenario is untouched. Measured locally at HEAD; each bump keeps the
usual headroom and is annotated in the config.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ough — no merge (#3105)

Merging one source is pure overhead, and the mergeProps memo consumed a
hydration id the SSR fast path never allocated, so every element after a
reactive lone spread went unclaimed. Both DOM generates now hand the
accessor straight to spread(), which resolves a function props source
inside its own tracking scopes (the Solid 1 shape). Server output is
untouched; the ids agree because neither side mints anything.

Supersedes the PR's server-side approach (deferring a merge until after
the element key): the client is the side doing unnecessary work.

The universal generate keeps its merge: it has no hydration ids to
drift, and its condition-memo insulation for arbitrarily expensive
custom-renderer props is a documented trade-off.

New hydration-parity harness scenario pins consecutive ids across the
spread (div _hk=0, button _hk=1) end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
…3111)

* test(web): pin three server-function invariants that nothing guards

The origin gate, the redirect mask's status set, and the error header's
bound each came from a recent fix, and each can be undone today without
a test failing.

- The gate has six branches; two were exercised. Sec-Fetch-Site is
  authoritative when present, so same-site and none are refused outright
  and never reach the trusted-origin matcher — loosening same-site is the
  natural-looking repair for a broken subdomain deployment. Without
  Sec-Fetch-Site, Origin decides and then Referer; a matcher that
  answered true for a non-matching origin would fail open unnoticed, and
  the Referer branch was unexercised entirely.

- The mask covers the statuses fetch follows, but only 302 was tested.
  Narrowing the set to {302, 303} leaves a scripted caller a real 307
  that fetch chases before the transport can read it.

- The error header's bound is applied by re-encoding a shrinking slice of
  the SOURCE; cutting the encoded form instead severs a percent escape
  and the value stops decoding. One message cannot tell the two apart —
  a naive slice survives whichever padding lands on an escape boundary —
  so the test sweeps six.

Each was checked by mutating the built runtime: loosening same-site
fails 1, narrowing the redirect set fails 3, slicing the encoding fails
5 of the 6 paddings. Tests only; no runtime change, so no changeset.

* test(web): drop a duplicated case, correct a comment, cover two matchers

Review of the first commit:

- The parameterised redirect test repeated 302, which the file already
  covers as "a returned redirect envelope: masked for scripted, real for
  unscripted". Narrowed to the four statuses nothing exercised, and the
  describe now carries its issue reference like its neighbours.

- The padding sweep's comment was wrong. Measured against a naive
  `encode(message).slice(0, LIMIT)`: padding 0 is the ONE case that still
  decodes, because the ceiling lands on an escape boundary — every other
  phase breaks. So the sweep was five copies of the same signal plus the
  one case that proves nothing. Two paddings state the property: the
  aligned case and a misaligned one.

- The matrix left `matchesOrigin`'s function and array branches
  unexercised, which is precisely the fail-open the commit message warned
  about. Both now have a negative case.

Each checked by mutating the built runtime: loosening same-site, a
function matcher that returns true, an array matcher that returns true,
and narrowing the redirect set each fail a test.

* test(web): match the file's idiom for the two-case parameterisations

`.each` appears once in all of packages/web/test; a plain loop reads the
same and drifts less. The four-status redirect case keeps it, where the
parameter is the point. Also a missing blank line.
…3112)

* test(web): mark three open server-function gaps as expected failures

Each is verified against `next` and stated as the behaviour that is
wanted, so the suite stays green while the gap is open and turns red the
day it closes.

- A result the codec cannot encode is delivered as `undefined`. The
  function already ran; only the encoding failed, and it failed after the
  head was committed, so the status is spent and no error tag can be
  added. The truncated body decodes to the same answer a void function
  gives, so a write that succeeded is indistinguishable from one that
  returned nothing.

- A streamed result has no backpressure: the stream is built with no
  `pull` and no queuing strategy, and every codec node is enqueued as
  soon as it is parsed. A consumer reading three chunks over 60ms left
  the producer 3695 items ahead; on a large or infinite stream one slow
  client buffers the whole result in server memory.

- The decode depth cap guards the seroval path only, and the body format
  is chosen by the caller, so selecting the JSON format opts out of it:
  depth 5000 decodes where the capped path answers 400.

`.fails` rather than the repo's `test.skip` idiom because the point is to
notice the fix. Tests only; no runtime change, so no changeset.

* test(web): fix the depth gap's header, and make two assertions honest

Review of the first commit turned up three things, one of which made a
test worthless:

- BODY_FORMAT_HEADER is not exported from the server entry, so the import
  was `undefined` and the request carried a header literally named
  "undefined". The JSON format was never selected and the function
  received no argument at all: the handler answered {"depth":0} where the
  gap needs {"depth":500}. The test failed, but not for its own reason —
  exactly the failure mode `.fails` cannot show you. Local const, as
  `server-functions-failure-signal.spec.tsx` already does.

- Depth 5000 sat on the repo's own cliff (shared.ts notes ~5900 nested
  objects overflow V8's default stack on CI). 500 is comfortably past the
  64-level cap and nowhere near it.

- The backpressure ceiling was wall-clock, and under `.fails` a starved CI
  that produced fewer than 500 would have turned red for no reason.
  Counted in event-loop turns instead: bounded stays near the queue size
  on any machine, unbounded tracks the turn count.

Also adopts MATRIX.md's spelling (`test.fails` with a `// GAP:` comment),
which is the repo's documented idiom for this and which the first commit
wrongly described as absent, and drops a dead assertion after
rejects.toThrow().

* test(web): point each gap marker at the issue tracking it

MATRIX.md's convention is a `// GAP:` comment; naming the issue in it
means whoever closes one finds the test that turns red.
…steps.

A ~60-line integration (runEffect + effectAction) shows Solid 2.0 consuming
Effect programs natively: superseded memo flights interrupt their fibers via
AsyncIterable return(), and a cancellable checkout saga runs Effect steps as
action transaction boundaries with typed errors, compensation, and optimistic
revert. Includes repro-close-timing.mjs documenting the isPending teardown
deferral filed as #3122.

Co-authored-by: Cursor <cursoragent@cursor.com>
Nothing bounded what a call could send: a 32 MB body was buffered and
decoded before any application code could decline it, and a modest
argument list forced a range error out of any function when spread into
the call. bodySizeLimit (default 1 MiB) refuses an oversized POST body or
?args= encoding with 413 before decoding — a declared Content-Length is
checked up front, a chunked body is buffered under the cap — and
maxArguments (default 1000) refuses an oversized argument list with 400.
Both configurable server-wide and per handler; Infinity removes a bound.

The decode depth cap now holds whichever body format the caller selects
(#3119): plain JSON walked into a bare JSON.parse with no ceiling where
the framed codec enforced 64 levels. The same ceiling now applies to
both, iteratively so the check cannot re-create the overflow it prevents,
and a non-array argument encoding answers 400 in either format. The
open-gaps marker for #3119 comes off; the test stays as an ordinary
guard.

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): sanitize failures that escape through the result graph

sanitizeServerError guards the one road a thrown error takes out of
dispatch. A failure can also escape through the RESULT GRAPH — a rejected
promise, an async iterable that throws, a stream that errors — where it
reaches the codec as a value to encode rather than as a throw, and never
meets the sanitizer. Same failure, different road, and the leak is the
exact one the sanitizer exists to stop: an ORM error's message and
own-properties (failing query, connection string, bound params) riding
the wire verbatim. Worse than the thrown case, because the head is
already committed, so the answer is a 200 carrying no error tag.

Claimed as a codec plugin rather than by walking the result: a walk would
have to run before serialization, and a rejection has not happened yet at
that point. The replacement is branded with markSafeError so the plugin
does not claim it again, which also leaves the wire shape a plain Error
node — the peer needs no matching plugin and the protocol is unchanged.
The sanitizer composes ahead of an app's own plugins: a custom error type
reaching the client is intent, and intent is spelled markSafeError.

#3095's authoring error is branded for the same reason — it names the
status the author got wrong, so it must stay readable.

* fix(web): sanitize failures that escape through the result graph

sanitizeServerError guards the one road a thrown error takes out of
dispatch. A failure can also escape through the RESULT GRAPH — a rejected
promise, an async iterable that throws, a stream that errors — where it
reaches the codec as a value to encode rather than as a throw, and never
meets the sanitizer. The leak is the one the sanitizer exists to stop: a
driver error's message and own-properties (failing query, connection
string, bound params) riding the wire verbatim, under a 200 carrying no
error tag because the head is already committed.

The three channels are wrapped before the codec sees them. Not the
rejection, which has not happened yet, and not the Errors already in the
graph: an Error reached as a value was never thrown, so it is data and
the author's to ship. Containers are rebuilt only along paths that
actually contain a channel, so a healthy response allocates nothing and
reference identity survives for the codec; a WeakMap keeps a repeated
reference one object and terminates cycles.

A first attempt claimed Error via a codec plugin. That was wrong twice
over: a plugin WRAPS rather than replaces, so the wire carried a plugin
node the peer had no plugin for, and sanitizeServerError's own unbranded
replacement was claimed too — breaking every sanitized error, including
the ordinary thrown one. Both are now regression tests.

#3095's authoring error is branded markSafeError: it names the status the
author got wrong, so it is intentional client-facing content.

* fix(web): walk data properties only

Reading through a getter invoked it during the walk as well as when the
codec encodes it, and a throwing one escaped into dispatch's catch to be
reported as the function itself failing — the phantom error over a call
that succeeded that encodeResult goes out of its way to avoid. Measured:
a result with a throwing getter answered 500 with the guard, 200 without.

Descriptors are carried across when a container is rebuilt, so a frozen
or non-writable shape survives. A channel behind an accessor is left
unguarded: invoking it is not ours to do.

* fix(web): guard the frames flight sink too

It encodes its outcome with its own serializer (serializeStream, not
serializeResponseStream), so the guard never reached it: a rejection
nested inside a flight-data slice arrived with its message and
own-properties intact, under a 200 with no error tag, on the same build
where the plain response path was already sanitized.

Reachable whenever the response routes through frames — a mutation whose
result is markup — with the failure nested one level inside a slice,
which is the ordinary shape of a cache entry. A rejection at the TOP of a
slice already threw into dispatch's catch and was sanitized; that is why
this looked covered.

The spec needs the frames server entry, hence the alias alongside the
other subpath aliases in vite.config.server.mjs.

* fix(web): make the walk cycle-safe, and cover Map and Set

Review found three defects in the first shape of the guard, all now
tests:

- A cycle recursed until the stack gave out, because the container was
  recorded in the WeakMap AFTER its children were walked. The RangeError
  then escaped into dispatch's catch as a 500 — on a shape seroval
  encodes natively as a back-reference. Containers are now recorded
  before descending, and a cycle forces the rebuild to stand since a
  descendant already holds it.

- A rejection inside a Map or Set reached the wire raw: neither was
  walked, and the changeset promised more than the code delivered.

- A null-prototype object on a channel path was rebuilt as a plain
  object, changing its node type from NullConstructor to Object. The
  prototype carries across now.

Also: the ReadableStream branch was dead — a stream is async-iterable on
every server runtime, so the iterator branch claimed it first — and it
acquired the reader eagerly at walk time. It now runs ahead of the
iterator branch and takes the reader on first pull.

The markSafeError on #3095's authoring error is dropped: that error is
encoded as a value and never routed through sanitizeServerError, so the
brand did nothing. Verified by removing it and re-running the spec that
asserts the message reaches the client.
When the codec could not encode a result the head was already committed —
status spent, no error tag possible — and the body simply stopped. A
truncated body decodes to undefined, the same answer a void function
gives, so a mutation that ran and committed was indistinguishable from
one that returned nothing, and a data layer might retry it.

The failure now travels in band: a terminal error-trailer frame, a
!-prefixed payload on the existing chunk framing (codec frames always
open with `{`, so the prefix is unambiguous). The decoder throws it — as
the call's failure when it is the first frame, and into every
still-pending async value when a later value's encoding fails mid-stream,
the delivered head keeping its data. Sanitized like any thrown error:
generic in production, cause preserved in dev. A crafted trailer in the
arguments answers 400. Version skew degrades safely — an old client
reading a trailer fails the call with a decode error rather than
resolving undefined.

The open-gaps marker for #3117 comes off; the test stays as an ordinary
guard, with the mid-stream and injection pins alongside.

Co-authored-by: Cursor <cursoragent@cursor.com>
The origin gate is skipped for GET-declared reads by design — same-origin
policy already keeps a cross-site caller from READING the response, and
the gate's Vary fragments the shared-cache entries the helper exists to
enable — but the premise the skip rests on lived nowhere: GET() did not
document it and nothing offered an alternative for deployments that do
not want the trade.

Declaring GET is now documented on both entries as the safety assertion
it is (the function becomes executable from any origin, with
caller-chosen arguments, carrying the user's ambient cookies — declare
only reads that are safe in the RFC 9110 section 9.2.1 sense), and
csrf.protectDeclaredReads opts a deployment's reads into the origin gate
when shared caches are not in play. Tests pin both halves: the default
skip, so nobody "fixes" it into cache poisoning, and the opt-in gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
#3120)

Three guards for what nothing previously watched:

- directives-id-scheme.test.js derives expected ids with an INDEPENDENT
  implementation — the xxhash32 the retired Babel pass used, plus the
  documented <name>-<hash>[-<ordinal>] format — and compares against what
  transformDirectives reports. A change to the hash, the relative-path
  derivation, or the format turns it red even if the frozen fixtures were
  regenerated wholesale.

- The repeated-names fixture pins the ordinal suffix byte-for-byte,
  including its assignment order: post-bubble program order (top-level
  declarations hoist in reverse source order, matching the frozen Babel
  reference), so makeB's submit takes the bare id. A traversal change that
  flips this re-points deployed addresses; now it turns a test red first.

- The fixtures README states that ids are a wire contract: a regeneration
  that changes any id is a protocol change to be reviewed as one, never
  waved through as fixture churn.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6eb97fa

The changes in this PR will be included in the next version bump.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

Copy link
Copy Markdown
Member

Thank you for taking this on! Unfortunately the branch was cut from main (our PR template still says that — sorry, it's stale for the 2.0 line, where everything targets next), so the diff replays 2,283 files of divergence and can't be merged as-is. The one-line fix itself is right, and it has landed in #3149 with regression tests at both the signals and web layers — you're credited in the changeset. One finding from review worth sharing: the crash was not universal-only; the 2.0 DOM renderer fails on the same shape (the 1.x insertExpression function branch no longer exists), so the fix mattered even more than the issue suggested. Closing this in favor of #3149 — hope to see you in the next release notes again.

@ryansolid ryansolid closed this Aug 31, 2026
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.

7 participants