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;
+}