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/manifest-client-entry-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/vite-plugin': patch
---

Fix the start-mode handler booting the wrong chunk when the client build has several configured inputs (#353, a regression of 3.0.0-next.40 / #347). With filesystem-routing's `fileRoutes({ routers: { client }, buildInputs: 'client' })` every route module is a `build.rollupOptions.input`, and since #347 those records rightly keep `isEntry` in `virtual:solid-manifest`. The generated handler resolved the client entry by scanning for the first `isEntry` record, so a route key sorting ahead of the plugin's own `virtual:solid-ssr-entry-client.tsx` won: the document's `<script type="module">` pointed at the route chunk (the page never hydrated) and `<head>` linked that route's CSS while the entry graph's global stylesheet was never linked. The manifest module now names the client entry explicitly — `_entry` carries its key (the entry start mode injects, or the single configured input outside start mode) and its record is serialized first — and the handler reads `_entry` before falling back to the `isEntry` scan for hand-rolled manifests. `@solidjs/web`'s `registerEntryAssets`, which links the entry graph's stylesheets and modulepreloads by the first `isEntry` record, therefore agrees on the same chunk. Other configured inputs keep `isEntry`; they are genuine entries, just not the one the document boots.
12 changes: 12 additions & 0 deletions examples/start-ssr/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const OnlyClient = clientOnly(() => import('./ClientOnlyWidget'));
// /@fs/ URL, not "/../…" (#298).
const LazyQuery = lazy(() => import('./QueryLazy.tsx?variant=a'));
const LazyOutside = lazy(() => import('../../start-ssr-external/LazyOutside'));
// Also a configured client build input in extra-input mode (#353): a lazily
// imported module that is a genuine entry too, like a filesystem router's
// `buildInputs` route modules.
const LazyExtraInput = lazy(() => import('./ExtraInput'));

function LazyAssetsSection() {
return (
Expand Down Expand Up @@ -108,6 +112,14 @@ export default function App() {
if (pathname === '/nested-lazy') return <NestedLazySection />;
// Lazy asset-key surfaces: query-suffixed and root-external modules (#298/#299).
if (pathname === '/lazy-assets') return <LazyAssetsSection />;
// The extra configured input, reached as a lazy route (extra-input mode).
if (pathname === '/extra-input') {
return (
<Loading fallback={<p>extra…</p>}>
<LazyExtraInput />
</Loading>
);
}

const [count, setCount] = createSignal(0);
const [message, setMessage] = createSignal('');
Expand Down
3 changes: 3 additions & 0 deletions examples/start-ssr/src/ExtraInput.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#extra-input {
color: rgb(90, 10, 10);
}
13 changes: 13 additions & 0 deletions examples/start-ssr/src/ExtraInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Extra configured client input (extra-input mode, EXTRA_CLIENT_INPUT=1 in
// vite.config.ts): a route-like module that is BOTH listed in the client
// build's `rollupOptions.input` — the shape filesystem-routing's
// `buildInputs` produces for every route module — and lazily imported by
// App.tsx. Its manifest record is therefore a genuine `isEntry` (#347) whose
// key sorts ahead of the plugin's own `virtual:` client entry; the built
// handler must still boot the page with the real entry and link the entry
// graph's CSS, not this module's (#353).
import './ExtraInput.css';

export default function ExtraInputPage() {
return <main id="extra-input">EXTRA-INPUT-PAGE</main>;
}
124 changes: 122 additions & 2 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,16 @@
// - a non-root Vite `base` (base mode, SOLID_BASE=/app/) holds end to end:
// dev pages/assets/endpoint and preview pages/statics/endpoint all serve
// base-prefixed, the built handler receives base-restored URLs from the
// preview adapter (#300), and dev lazy asset URLs carry the base (#298).
// preview adapter (#300), and dev lazy asset URLs carry the base (#298),
// - extra configured client inputs (extra-input mode, EXTRA_CLIENT_INPUT=1,
// the filesystem-routing `buildInputs` shape) don't displace the client
// entry: the built handler boots the real entry chunk and links the entry
// graph's stylesheet even though the extra input is an `isEntry` record
// sorting ahead of it (#353).
//
// Requires the plugin built (pnpm build at the repo root) and Google Chrome.
// Usage: node test/run.mjs
// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|base|builder-order|builder-prepare|babel-hmr|frames]
// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|base|builder-order|builder-prepare|extra-input|babel-hmr|frames]
// (default: all)

import { spawn, execSync } from 'node:child_process';
Expand Down Expand Up @@ -2091,6 +2096,119 @@ async function runBuilderOrderMode() {
}
}

// Extra configured client inputs (#353): EXTRA_CLIENT_INPUT=1 lists
// src/ExtraInput.tsx — a module App.tsx also lazily imports — as a further
// client build input, the shape filesystem-routing's `buildInputs` produces
// for every route module. Since #347 such inputs keep `isEntry` (they are
// genuine entries), so the manifest carries several flagged records and the
// extra one's key (`src/…`) sorts ahead of the plugin's `virtual:` entry. The
// built handler used to take the first `isEntry` record as the client entry:
// the page booted the route chunk and linked the route's CSS while the real
// entry's global stylesheet never made it into <head>. The manifest module
// now names its entry (`_entry`, serialized first) and the handler reads it.
async function runExtraInputMode() {
const mode = 'extra-input';
console.log(`\n=== ${mode.toUpperCase()} ===`);
const env = { ...process.env, EXTRA_CLIENT_INPUT: '1' };
const entryKey = 'virtual:solid-ssr-entry-client.tsx';
const extraKey = 'src/ExtraInput.tsx';

try {
rmSync(path.join(exampleDir, 'dist'), { recursive: true, force: true });
console.log(' building…');
execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env });
const clientManifest = JSON.parse(
readFileSync(path.join(exampleDir, 'dist/client/.vite/manifest.json'), 'utf-8'),
);
const entry = clientManifest[entryKey];
const extra = clientManifest[extraKey];
record(
mode,
'build',
'both configured inputs are manifest entries (extra one lazily imported too)',
!!entry?.isEntry &&
!!extra?.isEntry &&
!!entry?.css?.length &&
!!extra?.css?.length &&
(entry.dynamicImports ?? []).includes(extraKey),
`keys: ${Object.keys(clientManifest).join(', ')}`,
);
record(
mode,
'build',
'extra input sorts ahead of the client entry in manifest.json',
Object.keys(clientManifest).indexOf(extraKey) < Object.keys(clientManifest).indexOf(entryKey),
);
const serverBundle = readFileSync(path.join(exampleDir, 'dist/server/server.js'), 'utf-8');
record(
mode,
'build',
'baked manifest names the client entry (_entry) and keeps the extra input flagged',
serverBundle.includes(`"_entry": ${JSON.stringify(entryKey)}`) &&
(serverBundle.match(/"isEntry":\s*true/g) ?? []).length >= 2,
);

const handler = await import(
pathToFileURL(path.join(exampleDir, 'dist/server/server.js')).href + `?extra=${Date.now()}`
);
const html = await (await handler.handleRequest(new Request('http://localhost/'))).text();
const scriptSrc = html.match(/<script type="module" src="([^"]+)" async><\/script>/)?.[1];
const stylesheets = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)">/g)].map(
(m) => m[1],
);
record(mode, 'prod', 'app server-rendered', html.includes('SSR Start Mode'));
record(
mode,
'prod',
'client entry script is the real entry chunk',
!!entry?.file && scriptSrc === `/${entry.file}`,
`script: ${scriptSrc}, entry: ${entry?.file}, extra: ${extra?.file}`,
);
record(
mode,
'prod',
"entry graph's stylesheet linked in <head>",
!!entry?.css?.[0] && stylesheets.includes(`/${entry.css[0]}`),
`stylesheets: ${stylesheets.join(', ')}`,
);
record(
mode,
'prod',
'extra input chunk is neither the boot script nor a linked stylesheet on /',
!!extra?.file &&
scriptSrc !== `/${extra.file}` &&
!stylesheets.includes(`/${extra.css?.[0]}`) &&
!html.includes(extra.file),
`script: ${scriptSrc}, stylesheets: ${stylesheets.join(', ')}`,
);
// The extra input is still a working lazy target: rendering its route
// registers its own CSS alongside the entry's.
const extraHtml = await (
await handler.handleRequest(new Request('http://localhost/extra-input'))
).text();
const extraStylesheets = [...extraHtml.matchAll(/<link rel="stylesheet" href="([^"]+)">/g)].map(
(m) => m[1],
);
record(
mode,
'prod',
'lazy route to the extra input renders with both stylesheets and the real entry script',
extraHtml.includes('EXTRA-INPUT-PAGE') &&
extraStylesheets.includes(`/${entry?.css?.[0]}`) &&
extraStylesheets.includes(`/${extra?.css?.[0]}`) &&
extraHtml.includes(`<script type="module" src="/${entry?.file}" async></script>`),
`stylesheets: ${extraStylesheets.join(', ')}`,
);
} catch (e) {
record(mode, 'run', 'mode completed', false, String(e));
} finally {
// Leave dist in the standard state for anyone poking at it.
try {
execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe' });
} catch {}
}
}

// Builder-mode preparation: BUILD_PRE_WIPE=1 installs a nitro-v3-shaped
// host in vite.config.ts — a pre-order `buildApp` hook that rm -rf's dist
// before anything builds (nitro's `nitro:prepare`) and a post-order
Expand Down Expand Up @@ -4288,6 +4406,7 @@ const ALL_MODES = [
'base',
'builder-order',
'builder-prepare',
'extra-input',
'frames',
'babel-hmr',
'external',
Expand All @@ -4311,6 +4430,7 @@ for (const mode of modes) {
else if (mode === 'base') await runBaseMode();
else if (mode === 'builder-order') await runBuilderOrderMode();
else if (mode === 'builder-prepare') await runBuilderPrepareMode();
else if (mode === 'extra-input') await runExtraInputMode();
else if (mode === 'frames') await runFramesMode();
else if (mode === 'babel-hmr') await runBabelHmrMode();
else if (mode === 'external') await runExternalMode();
Expand Down
11 changes: 11 additions & 0 deletions examples/start-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import solidPlugin from '@solidjs/vite-plugin';
// - SOLID_SERVER_COMPONENTS points the generated entries at the
// server-components page and flips `serverFunctions: { components: true }`
// (frames mode) — the one-line enablement under test.
// - EXTRA_CLIENT_INPUT=1 (extra-input mode) lists src/ExtraInput.tsx — a
// module App.tsx also lazily imports — as a further client build input,
// the shape filesystem-routing's `buildInputs` produces for every route
// module (#353). Vite merges the plugin's injected entry into this array.
const jsxCompiler =
process.env.SOLID_JSX_COMPILER === 'babel' ? ('babel' as const) : ('native' as const);
const serverComponents = !!process.env.SOLID_SERVER_COMPONENTS;
Expand Down Expand Up @@ -108,6 +112,13 @@ export default defineConfig({
},
}
: {}),
...(process.env.EXTRA_CLIENT_INPUT
? {
environments: {
client: { build: { rollupOptions: { input: ['src/ExtraInput.tsx'] } } },
},
}
: {}),
...(process.env.BUILD_SSR_FIRST
? {
builder: {
Expand Down
99 changes: 97 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,80 @@ function normalizeEmittedLazyEntries(
}
}

/**
* The manifest key of THE client entry — the chunk whose `<script
* type="module">` boots the page and whose static import graph carries the
* global CSS. `isEntry` cannot answer this: every configured build input is
* a genuine entry (#347 keeps them flagged), and plugins routinely add more
* inputs than the application entry (filesystem-routing's `buildInputs`
* lists every route module, and route keys sort ahead of the plugin's own
* `virtual:` entry). So the identity comes from configuration instead: the
* entry start mode injected itself, or — outside start mode — the single
* configured input when there is exactly one (including Vite's default
* `index.html`). Several inputs and no start entry: no answer (null), and
* consumers keep their first-`isEntry` scan.
*
* Matched by key or `src`, the same two spellings `isConfiguredEntry` uses.
*/
function resolveClientEntryKey(
manifest: Record<string, any>,
startClientEntryId: string | null,
clientBuild: any,
root: string,
): string | null {
let entryId: string | null = startClientEntryId;
if (!entryId) {
const input = configuredBuildInput(clientBuild);
const raw =
input == null
? ['index.html']
: typeof input === 'string'
? [input]
: Array.isArray(input)
? input
: Object.values(input as Record<string, unknown>);
if (raw.length !== 1 || typeof raw[0] !== 'string') return null;
entryId = raw[0];
}
const { manifestKeys } = resolveConfiguredEntries(entryId, root);
for (const key in manifest) {
const record = manifest[key];
if (!record || typeof record !== 'object' || !record.file) continue;
if (manifestKeys.has(key) || (typeof record.src === 'string' && manifestKeys.has(record.src))) {
return key;
}
}
return null;
}

/**
* Serializes the plugin's manifest module with the client entry made
* explicit: `_entry` names its key (the generated handler reads it before
* falling back to scanning for `isEntry`), and its record is moved to the
* front. The ordering matters for consumers that still identify the entry
* by the first `isEntry` record — `@solidjs/web`'s `registerEntryAssets`,
* which links the entry graph's stylesheets and modulepreloads into
* `<head>`, and hand-rolled server entries — so they and `_entry` agree on
* the same chunk. Other configured inputs keep `isEntry`; they are genuine
* entries, just not the one the document boots.
*/
function stampClientEntry(
manifest: Record<string, any>,
entryKey: string | null,
base: string,
): Record<string, any> {
const ordered: Record<string, any> = {};
if (entryKey && manifest[entryKey]) {
ordered[entryKey] = manifest[entryKey];
}
for (const key in manifest) {
if (key !== entryKey) ordered[key] = manifest[key];
}
ordered._base = base;
if (entryKey && manifest[entryKey]) ordered._entry = entryKey;
return ordered;
}

export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
if (typeof options.ssr === 'object') {
throw new Error(
Expand Down Expand Up @@ -788,6 +862,11 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
// two-invocation build (`vite build --ssr`) still knows the client's
// entries when it bakes the client manifest in.
let clientBuildConfig: any = null;
// The client entry start mode injects into the client build's input
// (reported by startServe): the one input that IS the application entry,
// as opposed to further inputs other plugins add (e.g. filesystem-routing's
// `buildInputs`, which lists every route module). Null outside start mode.
let startClientEntryId: string | null = null;
let solidPkgsConfig: Awaited<ReturnType<typeof crawlFrameworkPkgs>>;
const tsrxCss = new Map<string, string>();

Expand Down Expand Up @@ -1334,8 +1413,13 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
warn: (message) => this.warn(message),
repairDynamicEntries: true,
});
manifest._base = base;
return `export default ${JSON.stringify(manifest)};`;
return `export default ${JSON.stringify(
stampClientEntry(
manifest,
resolveClientEntryKey(manifest, startClientEntryId, clientBuildConfig, projectRoot),
base,
),
)};`;
}
// SSR build before the client build produced a manifest: bake in the
// dev-shaped fallback (registry miss degrades to js-only resolution).
Expand Down Expand Up @@ -1714,6 +1798,9 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
// Normalize to forward slashes to match Vite's transform ids.
documentModuleId = documentPath ? documentPath.split(path.sep).join('/') : null;
},
onClientEntryResolved(entryId) {
startClientEntryId = entryId;
},
}),
);
}
Expand Down Expand Up @@ -1831,4 +1918,12 @@ export type ViteManifest = Record<
}
> & {
_base?: string;
/**
* Manifest key of the client entry the document boots (the plugin's
* injected start-mode entry, or the single configured input). Absent when
* the plugin cannot tell the application entry apart from other configured
* inputs; its record is also serialized first so first-`isEntry` scans
* agree with it.
*/
_entry?: string;
};
Loading
Loading