From 53bb865dca63ea8f5dae1e90c092ad91cf982733 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:04:08 +0000 Subject: [PATCH 1/2] fix(server-functions): ship server components' stylesheets in production (#456) The client compile replaces a 'use server' module with references, so the stylesheets it reached never joined the client graph. Dev's CSS crawl walks the SSR graph, so only production rendered server components unstyled. With components on, the client build now walks the module's imports (scripts are loaded, never imported) and imports the stylesheets it finds from the client module. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNQHxQ9311k7tQMEKs --- .changeset/server-component-styles.md | 5 ++ examples/start-ssr/src/frames/PanelBadge.tsx | 7 ++ examples/start-ssr/src/frames/data.tsx | 3 + examples/start-ssr/src/frames/panel-badge.css | 3 + examples/start-ssr/test/run.mjs | 10 +++ src/server-functions/index.ts | 8 +- src/server-functions/server-styles.ts | 73 +++++++++++++++++++ 7 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 .changeset/server-component-styles.md create mode 100644 examples/start-ssr/src/frames/PanelBadge.tsx create mode 100644 examples/start-ssr/src/frames/panel-badge.css create mode 100644 src/server-functions/server-styles.ts diff --git a/.changeset/server-component-styles.md b/.changeset/server-component-styles.md new file mode 100644 index 00000000..6210e6bb --- /dev/null +++ b/.changeset/server-component-styles.md @@ -0,0 +1,5 @@ +--- +"@solidjs/vite-plugin": patch +--- + +With `serverFunctions: { components: true }`, stylesheets that a `'use server'` module reaches (directly or through the components it imports) now reach the production client build, so the SSR document links them. The client compile replaces a server module with references, so its imports never joined the client graph: dev looked right, since its CSS crawl walks the SSR graph, but production rendered server components unstyled (#456). The client build now walks what the module imports and imports those stylesheets from its client module. Script modules on the way are only loaded, never imported, so no server code reaches the client bundle, and compile-time CSS plugins such as StyleX still see them. Builds without `components` are unchanged. diff --git a/examples/start-ssr/src/frames/PanelBadge.tsx b/examples/start-ssr/src/frames/PanelBadge.tsx new file mode 100644 index 00000000..59b693fd --- /dev/null +++ b/examples/start-ssr/src/frames/PanelBadge.tsx @@ -0,0 +1,7 @@ +// Imported only by the 'use server' module, so the client graph never sees +// it: its stylesheet must still reach the production client build (#456). +import './panel-badge.css'; + +export function PanelBadge() { + return server-styled; +} diff --git a/examples/start-ssr/src/frames/data.tsx b/examples/start-ssr/src/frames/data.tsx index ca768192..2ce8e8a8 100644 --- a/examples/start-ssr/src/frames/data.tsx +++ b/examples/start-ssr/src/frames/data.tsx @@ -5,6 +5,8 @@ // reference proxies in the client build — ROWS text must never reach any // client asset (the frames test scans for it). +import { PanelBadge } from './PanelBadge'; + const SECRET_ROW_TEXT = 'SERVER-ROW-TEXT'; const ROWS = [ @@ -51,6 +53,7 @@ export async function getPanel(name: string, version: number) { return (props: Record) => (

panel:{name}

+

version:{version}

counter:{counter}

diff --git a/examples/start-ssr/src/frames/panel-badge.css b/examples/start-ssr/src/frames/panel-badge.css new file mode 100644 index 00000000..a618b776 --- /dev/null +++ b/examples/start-ssr/src/frames/panel-badge.css @@ -0,0 +1,3 @@ +.panel-badge { + color: rgb(1, 2, 3); +} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 63641b90..df7657c4 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -3061,6 +3061,16 @@ async function runFramesChecks(mode, origin) { 'panel server-rendered and adopted', (await cdp.evalJs('document.querySelector("#panel-name")?.textContent')) === 'panel:alpha', ); + // #456: CSS reached only below the 'use server' boundary (PanelBadge) + // must style the document in production too, not just via dev's crawl. + record( + mode, + 'boot', + 'server-only component stylesheet applied (#456)', + (await cdp.evalJs( + 'getComputedStyle(document.querySelector("#panel-badge")).color', + )) === 'rgb(1, 2, 3)', + ); const bootFetches = cdp.requests.filter((u) => u.includes('/_server')); record( mode, diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index a14603df..731816fd 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -22,6 +22,7 @@ import { getEnvironmentConsumer, isRunnableEnvironment } from '../environment.js import { joinBase, parseRequestTarget, sendWebResponse, webRequestFromNode } from '../http.js'; import { isTsrxModule } from '../tsrx.js'; import { compile, type CompileOptions } from './compile.js'; +import { serverModuleStyles } from './server-styles.js'; import xxHash32 from './xxhash32.js'; /** @@ -791,10 +792,15 @@ export function createServerFunctionsPlugins( manifestId, ); + // A client build has no other way to learn a server component's + // stylesheets (dev's CSS crawl walks the SSR graph instead). + const styles = + components && isBuild && mode === 'client' ? await serverModuleStyles(ctx, id, code) : ''; + return { // Appended (not prepended) so the source map for the compiled module // stays valid; imports hoist and the endpoint is only read at call time. - code: (result.code || '') + endpointConfigureSnippet(mode), + code: (result.code || '') + endpointConfigureSnippet(mode) + styles, map: result.map, }; } diff --git a/src/server-functions/server-styles.ts b/src/server-functions/server-styles.ts new file mode 100644 index 00000000..046ae737 --- /dev/null +++ b/src/server-functions/server-styles.ts @@ -0,0 +1,73 @@ +// Stylesheets below a `'use server'` boundary, for the client build (#456). +// +// The client compile replaces a server module with references, so nothing it +// imported reaches the client graph: a server component's stylesheet gets no +// client asset and the SSR document links nothing for it. Dev never notices, +// since its CSS crawl walks the SSR graph. The walk below finds what the +// module reaches in the client environment and returns those stylesheets for +// the client module to import. Script modules are only loaded (which runs +// their transforms, so compile-time CSS such as StyleX records its rules), +// never imported, so no server code joins the client bundle. + +import { isTsrxCssModule } from '../tsrx.js'; + +const cssFileRegExp = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/; +// The importer controls a queried stylesheet; it is not an ambient style. +const nonAmbientQueryRegExp = /[?&](url|inline|raw)\b/; + +const isStylesheet = (id: string) => + (cssFileRegExp.test(id.split('?')[0]!) || isTsrxCssModule(id)) && + !nonAmbientQueryRegExp.test(id); + +/** The module's runtime import specifiers (type-only ones dropped). */ +function importSources(ctx: any, code: string): string[] { + let program; + try { + program = ctx.parse(code, { lang: 'tsx' }); + } catch { + return []; + } + const sources: string[] = []; + for (const node of program.body) { + if ( + (node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration') && + node.source && + node.importKind !== 'type' && + node.exportKind !== 'type' + ) { + sources.push(node.source.value); + } + } + return sources; +} + +/** Side-effect imports of the stylesheets a server module reaches. */ +export async function serverModuleStyles(ctx: any, id: string, code: string): Promise { + const seen = new Set([id]); + const styles = new Set(); + const visit = async (resolved: string) => { + if (seen.has(resolved)) return; + seen.add(resolved); + if (isStylesheet(resolved)) { + styles.add(resolved); + return; + } + let info; + try { + info = await ctx.load({ id: resolved }); + } catch { + // A module the client environment can't load has no client styles. + return; + } + for (const next of info?.importedIds ?? []) await visit(next); + }; + for (const source of importSources(ctx, code)) { + const resolved = await ctx.resolve(source, id).catch(() => null); + if (resolved && !resolved.external) await visit(resolved.id); + } + let out = ''; + for (const style of styles) out += `\nimport ${JSON.stringify(style)};`; + return out; +} From f3e568b3ad47bae24e9f57e47ea305df73aa8840 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:04:36 +0000 Subject: [PATCH 2/2] style: prettier Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNQHxQ9311k7tQMEKs --- examples/start-ssr/src/frames/PanelBadge.tsx | 6 +++++- src/server-functions/server-styles.ts | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/start-ssr/src/frames/PanelBadge.tsx b/examples/start-ssr/src/frames/PanelBadge.tsx index 59b693fd..9f5fbb9b 100644 --- a/examples/start-ssr/src/frames/PanelBadge.tsx +++ b/examples/start-ssr/src/frames/PanelBadge.tsx @@ -3,5 +3,9 @@ import './panel-badge.css'; export function PanelBadge() { - return server-styled; + return ( + + server-styled + + ); } diff --git a/src/server-functions/server-styles.ts b/src/server-functions/server-styles.ts index 046ae737..930892c4 100644 --- a/src/server-functions/server-styles.ts +++ b/src/server-functions/server-styles.ts @@ -16,8 +16,7 @@ const cssFileRegExp = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/; const nonAmbientQueryRegExp = /[?&](url|inline|raw)\b/; const isStylesheet = (id: string) => - (cssFileRegExp.test(id.split('?')[0]!) || isTsrxCssModule(id)) && - !nonAmbientQueryRegExp.test(id); + (cssFileRegExp.test(id.split('?')[0]!) || isTsrxCssModule(id)) && !nonAmbientQueryRegExp.test(id); /** The module's runtime import specifiers (type-only ones dropped). */ function importSources(ctx: any, code: string): string[] {