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..9f5fbb9b --- /dev/null +++ b/examples/start-ssr/src/frames/PanelBadge.tsx @@ -0,0 +1,11 @@ +// 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..930892c4 --- /dev/null +++ b/src/server-functions/server-styles.ts @@ -0,0 +1,72 @@ +// 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; +}