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>
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.