Skip to content

fix(web): do not cache a server component that throws during render; trim frames/server exports - #3961

Merged
ryansolid merged 6 commits into
nextfrom
fix/frame-error-not-cached
Oct 12, 2026
Merged

ryansolid merged 6 commits into
nextfrom
fix/frame-error-not-cached

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Before: respond(View, { headers: { "cache-control": "public, max-age=60" } }) copied those headers onto the frame response, and a throw during the component's synchronous render (user.id on null) still went out as a 200 carrying the :error record, so the author's Cache-Control stored the failure for a minute. Separately, @solidjs/web/frames/server exported four internal building blocks (renderServerComponent, renderToFrameStream, serverComponentResponse, createFrameSink) that apps have no reason to call.

After: a server component that throws during its synchronous render is sent Cache-Control: no-store, with the CDN-targeted cache fields removed. The status stays 200 and the body still carries the error record. The four building blocks are no longer exported; a server component reaches the client by being returned from a server function, directly or through respond(), and the transform hooks frame it.

How

The frame response is built inside the frames package, which already sees every record of the synchronous render as it writes the body, before the Response exists. An unkeyed error record there makes the head Cache-Control: no-store and drops CDN-Cache-Control, <CDN>-CDN-Cache-Control (RFC 9213) and Surrogate-Control. This is 19 lines in frame-sink.ts, with no new export, header or hook.

  • A render that succeeds keeps the author's headers, CDN fields included.
  • A returned { error } value keeps the author's headers. The author chose that value and the headers that travel with it.
  • A failure that lands after a suspended shell has already flushed cannot change the head, and still leaves with the author's headers.

Public API

The only change is a removal from the experimental @solidjs/web/frames/server entry: renderServerComponent, renderToFrameStream, serverComponentResponse and createFrameSink. It keeps frameTransformResult, frameTransformFlightResult, frameTransformDirectResult, ServerComponentPlugin, SERVER_COMPONENT_BOOTSTRAP, the frame header constants, isFrameStreamResponse and the slot types. Nothing in the repo or its examples imports the removed four from the public entry, and @solidjs/vite-plugin (3.0.0-next.35 and next.49) uses only the transform hooks and ServerComponentPlugin.

The first revision of this PR added an internal mark header (X-Solid-Failed-Value), re-exported FAILED_VALUE_HEADER, and changed the transport finalizer. All of that was reverted.

Test plan

  • frame-error-not-cached.spec.tsx (5 cases, all through the server-function handler): a sync throw is 200 and no-store with the error record; it drops the CDN fields and keeps Vary; success keeps public, max-age=60 and the CDN fields; respond({ error }, cacheable) keeps its headers. The throw cases fail with next's frame-sink.ts.
  • The two built-package probes that imported renderServerComponent from the public entry (dist-frames-server-instance.spec.ts, ssr-error-sanitization.fixture.mjs) now reach frames through handleServerFunctionRequest with frameTransformResult, the way an app does. Both pass in every tier, with the same sanitization findings as before.
  • Web client, server and hydration suites pass, apart from the one server-functions adapter test that also fails locally on next because of the Node version. test-types is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1

respond() headers describe the success. A synchronous throw is still a 200 whose body carries the error, so an author's cache-control stored the failure. The render marks that response and the transport finalizer sends it no-store, then strips the mark. A returned error value keeps the headers the author set.

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

changeset-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 81ae09d

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

@codspeed

codspeed Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 14.64%

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 193 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 30.8 ms 26.6 ms +15.5%
⚡ projection derive: write one NESTED field (reference) 2.4 ms 2.1 ms +13.79%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/frame-error-not-cached (81ae09d) with next (2322d64)

Open in CodSpeed

The frames server artifact resolves its shared.js imports through the server-functions server entry. FAILED_VALUE_HEADER was read there and never re-exported, so example SSR builds failed with a missing export.

Co-authored-by: Grok via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Oct 9, 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.44 KB 0 B 0 B +31 B 7.45 KB ✅
signals: + createStore 14.65 KB 0 B 0 B +23 B 14.71 KB ✅
signals: + isPending/latest 9.69 KB 0 B 0 B −100 B 9.73 KB ✅
app: render + one signal (the simple-app floor) 9.91 KB 0 B 0 B −39 B 9.94 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.72 KB 0 B 0 B −650 B 17.93 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.10 KB 0 B 0 B −551 B 29.27 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.97 KB 0 B 0 B −47 B 12.98 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.56 KB 0 B 0 B −31 B 14.56 KB ⚠️ over by 5 B, 51 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.86 KB 0 B 0 B −1 B 28.91 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.13 KB 0 B 0 B −57 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.42 KB 0 B 0 B −20 B 25.46 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.18 KB 0 B 0 B −627 B 31.39 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.43 KB 0 B 0 B 0 B 11.43 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.86 KB 0 B 0 B −2270 B 34.33 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.79 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.62 KB 0 B 0 B −2386 B 38.15 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.79 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) 34.75 KB 0 B 0 B −3429 B 35.66 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.75 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.24 KB 0 B 0 B −3492 B 41.21 KB ✅ eager (counted): web.js 21.25 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.77 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.26 KB 0 B 0 B −2257 B 41.79 KB ✅ assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 8.79 KB, wire.js 0.94 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.99 KB 0 B 0 B −2335 B 47.49 KB ✅ eager (counted): client.js 27.52 KB; assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.32 KB, trace.js 8.74 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)

  • app: CSR, observe tier (same app on the observe artifacts): over brotli cap by 5 B; minified 41,274 B vs 41,305 B recorded with the cap (−31 B) — 51 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).

A null literal asserted non-null is never, so user!.id failed the package typecheck. The value stays null at runtime.

Co-authored-by: Grok via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

coveralls commented Oct 12, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38296501253

Coverage remained the same at 77.429%

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: 1321
Covered Lines: 1079
Line Coverage: 81.68%
Relevant Branches: 1036
Covered Branches: 746
Branch Coverage: 72.01%
Branches in Coverage %: Yes
Coverage Strength: 27.01 hits per line

💛 - Coveralls

…onse

Rework of the internal-mark approach: no new export, no internal header and
no change to the public renderServerComponent result or createFrameSink hooks.
serverComponentResponse already sees every record of the sync pass in its own
write; an unkeyed error there makes the response no-store before the head is
built, and drops CDN-Cache-Control, <CDN>-CDN-Cache-Control and
Surrogate-Control, which would override Cache-Control at a CDN (RFC 9213).
A direct serverComponentResponse caller gets the same policy.
FAILED_VALUE_HEADER and the finalizer change are reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PDt8Jv6uf5xRDsrux4UwF
renderServerComponent, renderToFrameStream, serverComponentResponse and
createFrameSink were exported from @solidjs/web/frames/server, but apps
never call them: a server component is returned from a server function
(directly or through respond()) and the transform hooks frame it. Keep the
hooks and document-shell pieces as the entry's surface.

The direct serverComponentResponse case leaves the not-cached spec, and
the two built-package probes now reach frames through the server-function
handler, the way an app does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
@ryansolid ryansolid changed the title fix(web): do not cache a server component that throws during render fix(web): do not cache a server component that throws during render; trim frames/server exports Oct 12, 2026
@ryansolid
ryansolid merged commit 06c7717 into next Oct 12, 2026
8 checks passed
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.

3 participants