Skip to content

fix(web): unwrap respond() on direct calls; make its envelope a Response - #3910

Merged
ryansolid merged 1 commit into
nextfrom
fix/server-function-unwraps-respond
Oct 8, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/server-function-unwraps-respond

Conversation

@ryansolid

Copy link
Copy Markdown
Member

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.tsx returns respond(() => <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 the ResponseEnvelope itself, while an HTTP caller decoded value. For a server component wrapped for its headers, the envelope reached the render as a plain object: frameTransformDirectResult never 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 transformDirectResult runs. Of the envelope's metadata, only Set-Cookie is 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 (a GET's Cache-Control is 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 Response

ResponseEnvelope now extends Response, carrying the given response's body, status and headers (multiple Set-Cookie values preserved). A consumer that knows nothing about Solid, such as filesystem-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 the isResponseEnvelope() brand before instanceof Response, so they're unaffected.

The subclass is built on first construction: an extends Response evaluated 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) returns T. Every caller of the function returning it, over HTTP or in-process, receives the value.
  • redirect() and reload() are <T = never>(…): T. They're control flow for the integration, not values. never vanishes from the inferred return type's union, and where the context names a type (an annotated return, a Response-typed request handler), T takes it. A literal never return would also mark code after a bare call unreachable; a type parameter doesn't.

So a bare "use server" function returning respond(item) on one branch and redirect("/login") on another is typed Promise<Item>, with no wrapper narrowing needed (the router's NarrowResponse existed 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 raw Response only reaches code that reads them deliberately, such as a reading integration, which is typed against Response anyway. A hand-built new Response(...) stays Response-typed; wrappers that handle responses can filter it with Exclude<T, Response>.

Public API changes

  • Direct server-function calls that return or throw respond(value) now receive value instead of the ResponseEnvelope. Integrations that inspected the envelope on the direct leg (for example the router's query) no longer see it there.
  • During SSR, a directly called function's respond() Set-Cookie headers are appended to the page's response. Its other headers and its status are dropped on that leg.
  • ResponseEnvelope extends Response. new ResponseEnvelope(response, value) keeps its signature. envelope.response is now the envelope itself, or undefined when constructed without a response. instanceof Response is now true for envelopes.
  • respond() is typed T (was ResponseEnvelope<T>).
  • redirect() and reload() are typed <T = never>(…): T (were Response). Code that uses the result as a Response passes the type explicitly (reload<Response>(…)) or gets it from context.

Tests

  • New test/server/server-functions-direct-respond.spec.tsx (11 tests). It covers:

    • the envelope as a Response: brand, value, status, headers, cookies and JSON body; a bodiless 304; a plain fetch-style dispatcher; and both constructor forms;
    • the direct leg: async and sync returns, thrown envelopes, cookie-only forwarding with the status untouched, and frameTransformDirectResult branding respond(View).

    All 6 direct-leg tests failed before the fix.

  • New test/response-helpers.type-tests.ts. It covers:

    • respond's value type;
    • redirect/reload inferring never and accepting an explicit Response;
    • mixed redirect, reload and respond branches resolving to the value type;
    • annotated and Response-typed contexts;
    • GET(async () => respond(() => "view")) on client and server.
  • server-functions-outcome-digest.spec.tsx: one reload<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)

  • An unserializable async value during SSR with no onError throws from seroval's promise continuation, which hangs the render or kills the process instead of reaching an Errored boundary.
  • The router can drop NarrowResponse once it depends on this release.

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

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5f9e777

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

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB 0 B 0 B +15 B 7.45 KB ✅
signals: + createStore 14.69 KB 0 B 0 B 0 B 14.70 KB ✅
signals: + isPending/latest 9.63 KB 0 B 0 B +15 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.92 KB 0 B 0 B +15 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B +15 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.17 KB 0 B 0 B +55 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.92 KB 0 B 0 B +15 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.53 KB 0 B 0 B +15 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.85 KB 0 B 0 B +15 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB 0 B 0 B +15 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.22 KB 0 B 0 B +55 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.16 KB 0 B 0 B 0 B 31.17 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB 0 B 0 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.91 KB 0 B 0 B +161 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.61 KB 0 B 0 B +15 B 37.59 KB ⚠️ over by 20 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.15 KB 0 B 0 B +15 B 35.13 KB ⚠️ over by 16 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.18 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.65 KB 0 B 0 B +15 B 40.66 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.00 KB 0 B 0 B +15 B 46.02 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.33 KB 0 B 0 B +15 B 47.29 KB ⚠️ over by 36 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, trace.js 8.20 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 20 B; minified 117,456 B vs 117,441 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 16 B; minified 109,461 B vs 109,446 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 36 B; minified 148,683 B vs 148,668 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37751642091

Coverage remained the same at 76.43%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.37 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 188 untouched benchmarks


Comparing fix/server-function-unwraps-respond (5f9e777) with next (8d23a5a)

Open in CodSpeed

ryansolid added a commit that referenced this pull request Oct 8, 2026
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>
@ryansolid
ryansolid merged commit 49bed5c into next Oct 8, 2026
8 of 9 checks passed
ryansolid added a commit that referenced this pull request Oct 8, 2026
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>
ryansolid added a commit that referenced this pull request Oct 9, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants