Repository navigation
fix(web): unwrap respond() on direct calls; make its envelope a Response - #3910
Conversation
A server function called in-process during SSR that returned
respond(value) handed its caller the ResponseEnvelope itself, while an
HTTP caller decoded the value. A server component wrapped for its headers
(respond(View, { headers })) reached the render as an object, so the
frames policy never branded it and hydration serialization failed on it.
The direct leg now resolves with the value (a thrown envelope rejects
with it) before transformDirectResult runs, and appends the envelope's
Set-Cookie headers to the render's response head; other headers and the
status are the function's address's and the page's, not the document's.
ResponseEnvelope now extends Response (built lazily, so the module stays
free of side effects and safe where the global is missing), carrying the
given response's body, status and headers. A fetch-style consumer with no
Solid knowledge, such as a filesystem router's API dispatch, answers with
it as-is instead of JSON-encoding the wrapper.
The helpers are typed by what they mean to the caller: respond<T>()
returns T, and redirect()/reload() are <T = never>(...): T, vanishing
from inferred unions while taking a type from context (annotated returns,
Response-typed handlers). A bare "use server" function's signature is its
callers' type, with no wrapper narrowing needed.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 5f9e777 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 37751642091Coverage remained the same at 76.43%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
The server-components twin's routes are GET reads that answer through respond(View, cacheable): a minute of public HTTP caching, so a link hover's preload is the response the click then reads from the browser's cache. Both twins drop the routing dim: the current page stays up until the next is ready. Depends on respond() being typed as its value and envelopes unwrapping on the direct leg (#3910). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The server-components twin's routes are GET reads that answer through respond(View, cacheable): a minute of public HTTP caching, so a link hover's preload is the response the click then reads from the browser's cache. Both twins drop the routing dim: the current page stays up until the next is ready. Depends on respond() being typed as its value and envelopes unwrapping on the direct leg (#3910). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The server-components twin's routes are GET reads that answer through respond(View, cacheable): a minute of public HTTP caching, so a link hover's preload is the response the click then reads from the browser's cache. Both twins drop the routing dim: the current page stays up until the next is ready. Depends on respond() being typed as its value and envelopes unwrapping on the direct leg (#3910). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The server-components twin's routes are GET reads that answer through respond(View, cacheable): a minute of public HTTP caching, so a link hover's preload is the response the click then reads from the browser's cache. Both twins drop the routing dim: the current page stays up until the next is ready. Depends on respond() being typed as its value and envelopes unwrapping on the direct leg (#3910). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The server-components twin's routes are GET reads that answer through respond(View, cacheable): a minute of public HTTP caching, so a link hover's preload is the response the click then reads from the browser's cache. Both twins drop the routing dim: the current page stays up until the next is ready. Depends on respond() being typed as its value and envelopes unwrapping on the direct leg (#3910). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…3717) * examples: hackernews collapse as a binding slot, twins in the authoring layout The thread's collapse was a `Toggle` client component wrapping a template slot. It is now a binding slot on server markup: the recursive `Comment` is a server component, and each comment with replies calls `props.toggle({ $key: c.id })` and binds the toggle's `open` class name, its `onClick`, its label (a text position) and the replies' `display`. The fill is the SPA twin's `Toggle` without its markup: a signal in the body, getters over it, a plain handler. No client components remain. Both twins take the authoring layout: each route file holds its query with the server function inline (markup in one twin, data in the other), and `src/server/hn.ts` is a plain module behind `import "server-only"`, with the boundary types referenced from `vite-env.d.ts` (TypeScript 6 rejects the undeclared side-effect import). `lib/`, `api.ts`, `views.tsx` and `components/toggle.tsx` go. `CommentDefinition` gains `id` and `StoryDefinition.id` is a number, as the data carries. The READMEs lead with the collapse, name their coordinates, and correct the bundle check (grep the client JavaScript; `app.css` carries both class names). A server spec pins the class-array form at a binding position on both faces. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: hackernews server routes, nav outside Loading, router next.31 The feeds and the user page have no client half, so they become server routes: `serverRouteComponent(query(...))`, with the router making the call from the match on navigation and on link hover, so they need no route component and no `preload`. The feeds are one `/:type?` route filtered to the five feed names, with `page` read through a hand-written search schema. The story route keeps its component because of the collapse fill. The nav leaves the `Loading` boundary, since it does no I/O and the shell can wait on it. The SPA twin uses the same route pattern and filter, and parses `?page` itself. Router 2.0.0-next.29 sent a schema-less server route's args as `{ params, search: undefined }`. The JSON argument check rejects that, so client navigation to the user page silently never happened. next.30 fixed it (#615), so every example that uses the router moves to next.31. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: hackernews collapse as native details, navigation dim The collapse only hides replies, which is what <details> does natively, so the binding slot was a heavier way to do what the browser already does. Both twins now render the same <details> with a CSS label swap: the server twin's thread has no client code, its story route becomes a server route like the feeds and user page, and the SPA's Toggle component is deleted. The toggle styles move to the <summary> so font sizes don't compound with nesting. Markup stays byte-identical between the twins. Both roots dim the page while a navigation is pending (`useIsRouting()`), with a short delay so a navigation that hover preloading already made fast never flickers. The old page stays up instead of going blank. The plan records the revision, and that the occlusion proof (content a client component doesn't place ships once, as a record) has had no demo or end-to-end test since the early HN demo's deep-collapsed replies were dropped. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: notes in React's shape, end-to-end occlusion specs The notes demo moves to the authoring layout and toward the React original. The shell is a client component, as App.js is; only the sidebar list and the note preview are server components. The server shell had existed only to host the search field as a binding slot, and with the shell on the client the field is a plain client component. The editor's data is a plain query (getNoteEdit) instead of a server component used as a data loader. router.tsx holds the routes, the root preload and getNoteList, and server-config.ts passes that Router to the flight collector, as in the fullstack template. The excerpt now mounts only while expanded, as in the demo, so a collapsed excerpt ships once as an sc:region record and expanding it makes no request. The list is newest first and the excerpt is the rendered markdown as plain text, both matching the demo; the excerpt renders on the server, so marked stays in the lazy editor chunk only. The occlusion specs prove the single-copy claim end to end. The server spec checks each excerpt ships once (markup where placed, a record where not) and that late placement is locked to records. The hydration spec adopts the document with the network stubbed to throw, then expands, collapses and re-expands, with each text on screen once. The README, the grid plan and the principles doc are updated: the notes search field is no longer a binding-slot example, and the hackernews occlusion gap is closed. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: todos README names its coordinate, small cleanups The README opens with the example's place on the grid: the client-owned, request/response corner beside hackernews-spa, twin of todos-server, and the baseline for the planned board example. It points at todos.ts as the file to read. The todos.ts header keeps its instruction to read the file for its layering rather than as a list of React-to-Solid renames, but drops the rebuke. The error map moves from module scope into createTodos, so each instance has its own; the unused TodoActions type goes; the row checkbox uses onChange like toggle-all. Vite 7 -> 8, as in the other grid examples. The grid plan marks todos built. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: todos-server as forms with binding slots, router next.34, vite-plugin next.47 todos-server rebuilt around forms: every control is a form that works without JavaScript (`.with()` binds a row's id), and the server answers the page it was posted from in one round trip. Two binding slots, `row` and `list`, add back the optimism a form cannot express (toggle-all, clear-completed, the live count) from one intent store the actions' onSubmit hooks write. Failures are returned `{ error }` values read through useSubmissions; the no-JS path reads the same answers from the router's flash cookie. The README is rewritten around that shape. Dependencies across the examples: - @solidjs/router 2.0.0-next.34 in notes, room, hackernews, hackernews-spa and todos-server (server-rendered `.with()` forms find the registered action; the flash decode runs once per request). - @solidjs/vite-plugin 3.0.0-next.47 in all eleven, so they compile with the workspace compiler and babel plugin; next.47 is also the first to inject the flash-cookie secret. effect, migrating-element, rendering and sierpinski move to vite ^8, which next.47 requires (transformWithOxc). The plan's verification note records V1-V4 holding and the two router fixes they needed. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: router next.35 Actions now settle at the commit of their transition (#649): a form's aria-busy, the onSettled hooks and the submission record release when the result is on screen, not when the action body returns. Busy state is keyed by the form's action URL, so it survives a server-component morph. Also picks up the link-state fixes (aria-current compares the query, base path on a segment boundary, non-ASCII paths). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: todos-server shows intent through aria-busy, not an optimistic store The list no longer predicts outcomes. While a form's submission is in flight the router marks it aria-busy, and app.css shows the request from that alone: a pressed toggle, a dimmed row, and through :has() every row toggle-all or clear-completed touches. A mark waiting over two seconds pulses. The server's answer replaces the mark; the SPA twin keeps the predicting version. Failures are answers the server words (it knows the todo's title): the actions return { error } with the message, and the route lists them under the list with a retry, cleared when the next attempt at the same thing settles. The fills, the binding-slot bindings and the optimistic store are gone; the list has no client code. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: todos-server dismisses errors a later success answered A failed toggle's error stayed up after mark-all completed the todo, or after the todo was deleted. A settled attempt already replaced earlier answers to the same question within one action; now a successful delete also dismisses that todo's toggle errors, and a successful mark-all dismisses every row's. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: todos-server README names the render-mode condition for no-JS Without JavaScript the page has to arrive whole: streamed content that settles after the first flush needs a script to swap in. The example's reads land before the flush, which is timing, not a guarantee; an app that must work without JavaScript renders in the vite plugin's async mode. Also brings the toggle snippet in line with the markup (aria-pressed). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: router next.37, server components mount through dynamicComponent Every example moves to @solidjs/router 2.0.0-next.37, and the server component mounts in chat, notes, room and todos-server use dynamicComponent, the documented mount for a server component. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: hackernews routes answer cacheable GETs, no navigation dim The server-components twin's routes are GET reads that answer through respond(View, cacheable): a minute of public HTTP caching, so a link hover's preload is the response the click then reads from the browser's cache. Both twins drop the routing dim: the current page stays up until the next is ready. Depends on respond() being typed as its value and envelopes unwrapping on the direct leg (#3910). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: router 2.0.0-next.38, vite-plugin 3.0.0-next.48 Link preloading is opt-in in next.38, so each example's router passes intentPreload() and keeps the hover, focus, and touch preload it had. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: vite-plugin 3.0.0-next.49 Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * examples: record the grid handoff The apps that exist are as far as the runtime can take them. The plan names what landed, what stays, and what is handed off, and the chat, room, and migrating-element READMEs say the same. Co-authored-by: Grok via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): occlusion spec makes the regions tier resident before hydrate The spec predates #3860, which moved `{$frame}` slot regions into the lazily loaded regions tier: a record naming a region waits for the tier, so the slot fills never mounted and expanding item a rendered nothing. Load it before hydrate (as welcome-status-parity does for trace), assert the document announces it, and re-record the artifact, which predated the announcement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PDt8Jv6uf5xRDsrux4UwF --------- Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude <noreply@anthropic.com>
Moves the dev deps, lockfile and peer floor to rc.15 and updates the tests that relied on rc.14 behaviour: - claimElement is once per element since solidjs/solid#3924, so tests that simulate a morph re-claim now call claimElementTree (the sweep frames use), and the href re-claim test writes href through the compiler-owned setAttribute. - respond() is typed as its value and redirect() defaults to never (solidjs/solid#3910), so two server specs and one hash-history spec annotate the Response they use. - The solidjs#655 characterization's superseded-navigation timeline now lists data-pending attribute writes before the useLinkState render effects in the same flush, matching the single-navigation ordering. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2FCNsvaipU3xXx9p83VUs
Summary
respond()behaved differently depending on who called the server function, and its types described the object it builds rather than what callers receive. This fixes the runtime and the types together in@solidjs/web.Found while moving the hackernews server-components example to
GET(#3717):story.tsxreturnsrespond(() => <View />, { headers: { "cache-control": "public, max-age=60" } }), and during SSR the page broke.1. Direct calls unwrap the envelope
A server function called in-process during SSR (the direct leg in
createServerReference) handed its caller theResponseEnvelopeitself, while an HTTP caller decodedvalue. For a server component wrapped for its headers, the envelope reached the render as a plain object:frameTransformDirectResultnever branded it as a server component, and hydration serialization failed on it.The direct leg now resolves with the envelope's value (a thrown envelope rejects or throws with it) before
transformDirectResultruns. Of the envelope's metadata, onlySet-Cookieis appended to the render's response head: a cookie is state the function established, and the browser must receive it whichever leg ran the call. The other headers describe the function's own address (aGET'sCache-Controlis about that URL, not the page composed from it), and the status is the page's, so neither is applied to the document.2. The envelope is a
ResponseResponseEnvelopenow extendsResponse, carrying the given response's body, status and headers (multipleSet-Cookievalues preserved). A consumer that knows nothing about Solid, such asfilesystem-routing's API dispatch (result instanceof Response ? result : Response.json(result)), now answers with it as-is instead of JSON-encoding the wrapper object. Solid's own consumers already check theisResponseEnvelope()brand beforeinstanceof Response, so they're unaffected.The subclass is built on first construction: an
extends Responseevaluated at module load would throw wherever the global is missing, and a top-level class with a prototype write would pin itself into every bundle importing the module.3. The helpers are typed by what they mean to the caller
respond<T>(value, init)returnsT. Every caller of the function returning it, over HTTP or in-process, receives the value.redirect()andreload()are<T = never>(…): T. They're control flow for the integration, not values.nevervanishes from the inferred return type's union, and where the context names a type (an annotated return, aResponse-typed request handler),Ttakes it. A literalneverreturn would also mark code after a bare call unreachable; a type parameter doesn't.So a bare
"use server"function returningrespond(item)on one branch andredirect("/login")on another is typedPromise<Item>, with no wrapper narrowing needed (the router'sNarrowResponseexisted to do this after the fact). In normal use the client runtime already acts on redirect and revalidate responses: mutations deliver them to the registered flight consumers and resolve with the value. A rawResponseonly reaches code that reads them deliberately, such as a reading integration, which is typed againstResponseanyway. A hand-builtnew Response(...)staysResponse-typed; wrappers that handle responses can filter it withExclude<T, Response>.Public API changes
respond(value)now receivevalueinstead of theResponseEnvelope. Integrations that inspected the envelope on the direct leg (for example the router'squery) no longer see it there.respond()Set-Cookieheaders are appended to the page's response. Its other headers and its status are dropped on that leg.ResponseEnvelopeextendsResponse.new ResponseEnvelope(response, value)keeps its signature.envelope.responseis now the envelope itself, orundefinedwhen constructed without a response.instanceof Responseis now true for envelopes.respond()is typedT(wasResponseEnvelope<T>).redirect()andreload()are typed<T = never>(…): T(wereResponse). Code that uses the result as aResponsepasses the type explicitly (reload<Response>(…)) or gets it from context.Tests
New
test/server/server-functions-direct-respond.spec.tsx(11 tests). It covers:Response: brand, value, status, headers, cookies and JSON body; a bodiless 304; a plain fetch-style dispatcher; and both constructor forms;frameTransformDirectResultbrandingrespond(View).All 6 direct-leg tests failed before the fix.
New
test/response-helpers.type-tests.ts. It covers:respond's value type;redirect/reloadinferringneverand accepting an explicitResponse;Response-typed contexts;GET(async () => respond(() => "view"))on client and server.server-functions-outcome-digest.spec.tsx: onereload<Response>(…)where the test reads headers off the result.Web server suite: 162 files, 1,535 passed. Web test type-check clean.
Follow-ups (not in this PR)
onErrorthrows from seroval's promise continuation, which hangs the render or kills the process instead of reaching an Errored boundary.NarrowResponseonce it depends on this release.