Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-component-styles.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions examples/start-ssr/src/frames/PanelBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span id="panel-badge" class="panel-badge">
server-styled
</span>
);
}
3 changes: 3 additions & 0 deletions examples/start-ssr/src/frames/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -51,6 +53,7 @@ export async function getPanel(name: string, version: number) {
return (props: Record<string, any>) => (
<section class="panel">
<h2 id="panel-name">panel:{name}</h2>
<PanelBadge />
<p id="server-version">version:{version}</p>
<p id="server-counter">counter:{counter}</p>
<div class="rows">
Expand Down
3 changes: 3 additions & 0 deletions examples/start-ssr/src/frames/panel-badge.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.panel-badge {
color: rgb(1, 2, 3);
}
10 changes: 10 additions & 0 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src/server-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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,
};
}
Expand Down
72 changes: 72 additions & 0 deletions src/server-functions/server-styles.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const seen = new Set([id]);
const styles = new Set<string>();
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;
}
Loading