Skip to content

Commit ec29b48

Browse files
fix the include side of start.css.filter (#316 follow-up): the option's stated use case — opting a node_modules dependency's graph into dev SSR CSS collection — could not work under the raw createFilter composition, twice over: the default /node_modules/ exclude was applied whenever the user set no exclude and createFilter's exclude-wins rule vetoed the included package, and a non-empty include turned the filter into a strict allowlist that rejected the app's own sources, pruning the crawl at the entry roots — an include-only config silently stripped ALL dev SSR CSS (live-repro'd in examples/start-ssr: include /some-ui-lib/ lost App.css from the streamed head while the page still rendered). The crawl walks the graph from the app's entries, so allowlist semantics are structurally wrong for this option; include now RESCUES files on top of the baseline (everything except exclude, which still defaults to /node_modules/ and still gets replaced — not extended — by a user exclude), composed from two createFilters so a file matching both patterns stays excluded, exactly createFilter's own conflict rule. Default and exclude-only behavior are byte-identical to before; empty-array includes are treated as absent (createFilter would read them as allow-all). Coverage the exclude side always had, now mirrored for include: the css-filter mode grew from 2 to 9 assertions across four dev-server sub-runs (exclude / include / conflict / default) against a temp node_modules package written by the harness — a real directory, not a symlink or file: dep, which Vite would realpath outside node_modules and dodge the default exclusion under test — whose JS imports its own CSS (the filter sees JS modules; CSS files bypass it), ssr.noExternal'd so the SSR env can transform the CSS import. Docs that both next.31 start options were missing: README options.start now documents css.filter (include/exclude semantics, dev-only scope) and #315's errorBoundary (generic 500 fallback, no leaked details, dev untouched, errorBoundary: false when middleware owns errors), and the option list gains the absent setup/errorBoundary/css entries; the StartOptions JSDoc for css.filter spells the same semantics. No new changeset — nothing has shipped (#316's changeset is queued unreleased in #313), so the existing start-css-filter changeset's wording is corrected to describe the fixed semantics instead. Full local gate green: start-ssr 349/349 + http-bridge 10/10, css-matrix 82/82 + bridge 19/19, build + tsc clean.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0783df5 commit ec29b48

6 files changed

Lines changed: 231 additions & 33 deletions

File tree

.changeset/start-css-filter.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@solidjs/vite-plugin': patch
33
---
44

5-
Add `start.css.filter` to control which module graphs are traversed while collecting development CSS.
5+
Add `start.css.filter` to control which module graphs are traversed while collecting development CSS. `exclude` prunes matching graphs (defaults to `/node_modules/`; providing one replaces the default), and `include` opts matching files in on top of that baseline — e.g. `{ include: /node_modules\/some-ui-lib/ }` server-inlines that dependency's CSS in dev. A file matching both patterns stays excluded.

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ same server functions.
179179
The object form carries the options (`start: true` is pure sugar for
180180
`start: {}` — both mean the identical start mode with defaults, and
181181
`false`/absent means off): `app`, `document`, `entryServer`, `entryClient`,
182-
`middleware`, `env`, `external`, all documented below.
182+
`middleware`, `setup`, `env`, `errorBoundary`, `css`, `external`, all
183+
documented below.
183184

184185
```tsx
185186
// src/App.tsx — the entire app: a plain content component
@@ -429,6 +430,43 @@ client values, the leak scan — follows
429430
design-correct prior art, reimplemented on this plugin's machinery with
430431
Standard Schema as the only contract (and runtime-read server values).
431432

433+
**`errorBoundary`** — in a production build, generated entries wrap the app
434+
in a default error boundary (and the document in an outer one): a render
435+
error streams a generic `500 | Internal Server Error` fallback — no stack
436+
or error details reach the HTML; the error itself goes to `console.error`
437+
— and an error caught before the shell flushes commits a real 500 status
438+
through the response-head lifecycle. Development is unaffected (Vite's
439+
error overlay owns dev errors), as are authored entries — the boundary is
440+
generated-entry codegen. Disable it with `start: { errorBoundary: false }`
441+
when application middleware owns error handling (an error middleware only
442+
sees the throw when no boundary catches it first). Default: `true`.
443+
444+
**`css.filter`** — include/exclude patterns
445+
([picomatch](https://github.com/micromatch/picomatch) globs or regexes;
446+
relative globs resolve against the Vite root) for the module graphs the dev
447+
server crawls when collecting the CSS it inlines into `<head>` (the no-FOUC
448+
guarantee). By default the crawl covers the app's own sources and skips
449+
`node_modules`. `exclude` prunes matching graphs — providing one replaces
450+
the default `node_modules` exclusion — and `include` opts matching files in
451+
on top of that baseline, which is how a dependency's CSS gets
452+
server-inlined in dev:
453+
454+
```ts
455+
solid({
456+
start: {
457+
css: { filter: { include: /node_modules\/some-ui-lib/ } },
458+
},
459+
ssr: true,
460+
});
461+
```
462+
463+
CSS files themselves and virtual modules always pass — the filter decides
464+
which module graphs are traversed, not which stylesheets are kept — and a
465+
file matching both patterns stays excluded (Vite `createFilter`'s
466+
conflict rule). Development only: excluding a graph does not remove its
467+
CSS from the production build, where CSS always comes from the built
468+
assets.
469+
432470
**Entry resolution** (all paths relative to the Vite root):
433471

434472
1. Explicit `start.entryServer` / `start.entryClient` options.

examples/start-ssr/test/run.mjs

Lines changed: 128 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@
7878
// generated entries and client assets carry no reference to the
7979
// server-components runtime when the option is off.
8080
//
81+
// - `start.css.filter` (css-filter mode, CSS_FILTER=<shape> in
82+
// vite.config.ts): the dev CSS crawl's include/exclude semantics against
83+
// a temp node_modules package written by the test — `exclude` prunes an
84+
// app graph (replacing the default node_modules exclusion), `include`
85+
// opts the package's graph in on top of the default baseline with app
86+
// CSS surviving (the PR #316 use case), a file matching both patterns
87+
// stays excluded, and the filterless default prunes node_modules,
8188
// - the response-head lifecycle in dev, prod, and preview (App.tsx's
8289
// path-keyed surfaces): httpStatus(404)/httpHeader reach the wire, a
8390
// pre-flush Location is a real 3xx with no body, a post-flush Location
@@ -105,13 +112,13 @@
105112
//
106113
// Requires the plugin built (pnpm build at the repo root) and Google Chrome.
107114
// Usage: node test/run.mjs
108-
// [dev|prod|document|entries|endpoint|configure|no-middleware|middleware|preview|base|builder-order|builder-prepare|babel-hmr|frames]
115+
// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|base|builder-order|builder-prepare|babel-hmr|frames]
109116
// (default: all)
110117

111118
import { spawn, execSync } from 'node:child_process';
112119
import { fileURLToPath, pathToFileURL } from 'node:url';
113120
import path from 'node:path';
114-
import { rmSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
121+
import { mkdirSync, rmSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
115122
import http from 'node:http';
116123
import {
117124
createServer,
@@ -1127,30 +1134,131 @@ async function runDocumentMode() {
11271134
}
11281135
}
11291136

1137+
// Distinctive rule from the temp test-css-lib package's stylesheet: proves a
1138+
// node_modules graph's CSS was opted into the dev SSR crawl by
1139+
// `start.css.filter.include`. Keep in sync with CSS_LIB_FIXTURES below.
1140+
const LIB_CSS_COLOR = 'rgb(7, 140, 210)';
1141+
1142+
// Temp fixtures for the css-filter mode (written before the sub-runs,
1143+
// removed after): a real — not symlinked — node_modules package whose JS
1144+
// imports its own CSS (the JS module is what the filter sees; CSS files
1145+
// themselves bypass it), plus an app wrapper that pulls the package into
1146+
// the entry graph. Real directory matters: Vite resolves symlinks, and a
1147+
// `file:` dependency would resolve outside node_modules, dodging the
1148+
// default exclusion under test.
1149+
const CSS_LIB_FIXTURES = {
1150+
'node_modules/test-css-lib/package.json': JSON.stringify(
1151+
{ name: 'test-css-lib', version: '0.0.0', type: 'module', main: 'index.js' },
1152+
null,
1153+
2,
1154+
),
1155+
'node_modules/test-css-lib/index.js': `import './styles.css';\nexport const cssLibReady = true;\n`,
1156+
'node_modules/test-css-lib/styles.css': `#lib-probe {\n color: ${LIB_CSS_COLOR};\n}\n`,
1157+
'src/CssLibApp.tsx': `import 'test-css-lib';
1158+
import App from './App';
1159+
1160+
export default function CssLibApp() {
1161+
return <App />;
1162+
}
1163+
`,
1164+
};
1165+
11301166
async function runCssFilterMode() {
11311167
const mode = 'css-filter';
11321168
console.log(`\n=== ${mode.toUpperCase()} ===`);
1133-
const port = 3172;
1134-
const origin = `http://localhost:${port}`;
1135-
const server = startProcess('pnpm', ['exec', 'vite', '--port', String(port), '--strictPort'], {
1136-
cwd: exampleDir,
1137-
env: { ...process.env, CSS_FILTER: '1' },
1138-
});
1139-
let serverLog = '';
1140-
server.stdout.on('data', (d) => (serverLog += d));
1141-
server.stderr.on('data', (d) => (serverLog += d));
1169+
1170+
for (const [file, source] of Object.entries(CSS_LIB_FIXTURES)) {
1171+
mkdirSync(path.dirname(path.join(exampleDir, file)), { recursive: true });
1172+
writeFileSync(path.join(exampleDir, file), source);
1173+
}
1174+
1175+
// One dev server per filter shape (the option is config-time); each
1176+
// sub-run asserts on the streamed dev SSR HTML only.
1177+
const subRuns = [
1178+
{
1179+
filter: 'exclude',
1180+
port: 3172,
1181+
checks: (html) => {
1182+
record(mode, 'exclude', 'excluded module graph is not crawled for CSS', !html.includes(APP_CSS_COLOR));
1183+
record(mode, 'exclude', 'filter does not prevent app rendering', html.includes('SSR Start Mode'));
1184+
},
1185+
},
1186+
{
1187+
filter: 'include',
1188+
port: 3173,
1189+
checks: (html) => {
1190+
record(
1191+
mode,
1192+
'include',
1193+
'included node_modules graph is crawled for CSS',
1194+
html.includes(LIB_CSS_COLOR),
1195+
);
1196+
record(
1197+
mode,
1198+
'include',
1199+
'include-only filter keeps collecting app CSS',
1200+
html.includes(APP_CSS_COLOR),
1201+
);
1202+
record(mode, 'include', 'filter does not prevent app rendering', html.includes('SSR Start Mode'));
1203+
},
1204+
},
1205+
{
1206+
filter: 'conflict',
1207+
port: 3174,
1208+
checks: (html) => {
1209+
record(
1210+
mode,
1211+
'conflict',
1212+
'file matching include and exclude stays excluded',
1213+
!html.includes(APP_CSS_COLOR),
1214+
);
1215+
record(mode, 'conflict', 'filter does not prevent app rendering', html.includes('SSR Start Mode'));
1216+
},
1217+
},
1218+
{
1219+
filter: 'default',
1220+
port: 3175,
1221+
checks: (html) => {
1222+
record(
1223+
mode,
1224+
'default',
1225+
'node_modules graph is pruned without a filter',
1226+
!html.includes(LIB_CSS_COLOR),
1227+
);
1228+
record(mode, 'default', 'app CSS is collected without a filter', html.includes(APP_CSS_COLOR));
1229+
},
1230+
},
1231+
];
11421232

11431233
try {
1144-
await waitForHttp(origin + '/src/api.ts', 30000);
1145-
const { html } = await fetchStreamed(origin + '/');
1146-
record(mode, 'css', 'excluded module graph is not crawled for CSS', !html.includes(APP_CSS_COLOR));
1147-
record(mode, 'ssr', 'filter does not prevent app rendering', html.includes('SSR Start Mode'));
1148-
} catch (error) {
1149-
record(mode, 'run', 'mode completed', false, String(error) + serverLog.slice(-2000));
1234+
for (const { filter, port, checks } of subRuns) {
1235+
const origin = `http://localhost:${port}`;
1236+
const server = startProcess(
1237+
'pnpm',
1238+
['exec', 'vite', '--port', String(port), '--strictPort'],
1239+
{
1240+
cwd: exampleDir,
1241+
env: { ...process.env, CSS_FILTER: filter },
1242+
},
1243+
);
1244+
let serverLog = '';
1245+
server.stdout.on('data', (d) => (serverLog += d));
1246+
server.stderr.on('data', (d) => (serverLog += d));
1247+
try {
1248+
await waitForHttp(origin + '/src/api.ts', 30000);
1249+
const { html } = await fetchStreamed(origin + '/');
1250+
checks(html);
1251+
} catch (error) {
1252+
record(mode, filter, 'sub-run completed', false, String(error) + serverLog.slice(-2000));
1253+
} finally {
1254+
try {
1255+
process.kill(-server.pid, 'SIGTERM');
1256+
} catch {}
1257+
}
1258+
}
11501259
} finally {
1151-
try {
1152-
process.kill(-server.pid, 'SIGTERM');
1153-
} catch {}
1260+
rmSync(path.join(exampleDir, 'node_modules/test-css-lib'), { recursive: true, force: true });
1261+
rmSync(path.join(exampleDir, 'src/CssLibApp.tsx'), { force: true });
11541262
}
11551263
}
11561264

examples/start-ssr/vite.config.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export default defineConfig({
5858
define: {
5959
__JSX_COMPILER__: JSON.stringify(jsxCompiler),
6060
},
61+
// test-css-lib is written into node_modules by the css-filter mode; it
62+
// imports its own CSS, so the SSR environment must transform it (a
63+
// Node-externalized import of a bare .css file would crash the render).
64+
...(process.env.CSS_FILTER === 'include' || process.env.CSS_FILTER === 'default'
65+
? { ssr: { noExternal: ['test-css-lib'] } }
66+
: {}),
6167
// VITEST_PROJECTS=1 (vitest mode): both test postures in ONE workspace —
6268
// DOM component tests under jsdom get the client posture (browser
6369
// conditions, dom codegen), while the node project gets the server
@@ -109,9 +115,28 @@ export default defineConfig({
109115
? { document: process.env.SSR_DOCUMENT }
110116
: {
111117
external: !!process.env.SOLID_EXTERNAL,
112-
...(process.env.CSS_FILTER
118+
// CSS_FILTER (css-filter mode) exercises `start.css.filter`
119+
// against a temp app (src/CssLibApp.tsx, written by the test)
120+
// whose graph pulls a temp node_modules package with CSS
121+
// (test-css-lib, also written by the test):
122+
// - exclude: prune the App.tsx graph (replaces the default
123+
// node_modules exclusion).
124+
// - include: opt the test-css-lib graph in on top of the
125+
// default baseline (app CSS must survive).
126+
// - conflict: the same file matched by both patterns stays
127+
// excluded (createFilter's exclude-wins rule).
128+
// - default: no filter — the node_modules graph is pruned by
129+
// the default exclusion while app CSS is collected.
130+
...(process.env.CSS_FILTER === 'exclude'
113131
? { css: { filter: { exclude: /App\.tsx$/ } } }
114132
: {}),
133+
...(process.env.CSS_FILTER === 'include'
134+
? { app: 'src/CssLibApp.tsx', css: { filter: { include: /test-css-lib/ } } }
135+
: {}),
136+
...(process.env.CSS_FILTER === 'conflict'
137+
? { css: { filter: { include: /App\.tsx$/, exclude: /App\.tsx$/ } } }
138+
: {}),
139+
...(process.env.CSS_FILTER === 'default' ? { app: 'src/CssLibApp.tsx' } : {}),
115140
// SSR_MIDDLEWARE=1 (middleware/preview modes): a fetch-style
116141
// chain fronting every dispatch path — page SSR, /_server,
117142
// preview — with getRequestEvent() live inside it.

src/index.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -557,10 +557,26 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
557557
const startOptions: StartOptions | null =
558558
options.start === true ? {} : options.start || null;
559559
const styleFilterOptions = startOptions?.css?.filter;
560-
let styleFilter = createFilter(
561-
styleFilterOptions?.include,
562-
styleFilterOptions?.exclude ?? DEFAULT_STYLE_EXCLUDE,
563-
);
560+
// The CSS crawl walks the module graph from the app's own entries, so a
561+
// plain createFilter allowlist can't express the option's purpose (opting
562+
// node_modules graphs in): a bare `include` would reject the app sources
563+
// the crawl has to traverse to ever reach the included package. Instead
564+
// `include` rescues files on top of the baseline (everything except
565+
// `exclude`, which defaults to node_modules), while a file matching both
566+
// patterns stays excluded — createFilter's own conflict rule.
567+
const createStyleFilter = (resolve?: string) => {
568+
const opts = resolve === undefined ? undefined : { resolve };
569+
const base = createFilter(
570+
undefined,
571+
styleFilterOptions?.exclude ?? DEFAULT_STYLE_EXCLUDE,
572+
opts,
573+
);
574+
const include = styleFilterOptions?.include;
575+
const hasInclude = include != null && (!Array.isArray(include) || include.length > 0);
576+
const included = hasInclude ? createFilter(include, styleFilterOptions?.exclude, opts) : null;
577+
return (id: string) => base(id) || (included ? included(id) : false);
578+
};
579+
let styleFilter = createStyleFilter();
564580
const filterDevStyles = (id: string) => styleFilter(id);
565581
// `start.external` only means something when a server side exists to hand
566582
// over (SSR start mode); in client mode it is a documented no-op.
@@ -852,11 +868,7 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
852868
base = config.base;
853869
projectRoot = config.root;
854870
filter = createFilter(options.include, options.exclude, { resolve: projectRoot });
855-
styleFilter = createFilter(
856-
styleFilterOptions?.include,
857-
styleFilterOptions?.exclude ?? DEFAULT_STYLE_EXCLUDE,
858-
{ resolve: projectRoot },
859-
);
871+
styleFilter = createStyleFilter(projectRoot);
860872
if (serverComponents && !(options.start && options.ssr)) {
861873
config.logger.warn(
862874
'[@solidjs/vite-plugin] serverFunctions.components is set without SSR start mode (the `start` ' +

src/ssr/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,22 @@ export interface StartOptions {
8181
app?: string;
8282
/** Options for development CSS crawling. */
8383
css?: {
84-
/** Filter files traversed while collecting CSS. */
84+
/**
85+
* Filter for the modules traversed while collecting the CSS that dev
86+
* SSR inlines into `<head>`. Patterns are
87+
* [picomatch](https://github.com/micromatch/picomatch) globs or regexes;
88+
* relative globs resolve against the Vite root. CSS files themselves
89+
* and virtual modules always pass — the filter decides which module
90+
* graphs are crawled, not which stylesheets are kept.
91+
*
92+
* `exclude` prunes matching graphs and defaults to `/node_modules/`
93+
* (providing your own replaces the default). `include` opts matching
94+
* files back in on top of that baseline — typically a package whose
95+
* CSS should be server-inlined to avoid a development FOUC, e.g.
96+
* `{ include: /node_modules\/some-ui-lib/ }`. A file matching both
97+
* stays excluded. Development only: production CSS always comes from
98+
* the built assets.
99+
*/
85100
filter?: {
86101
include?: FilterPattern;
87102
exclude?: FilterPattern;

0 commit comments

Comments
 (0)