diff --git a/.changeset/protect-configured-entry-chunks.md b/.changeset/protect-configured-entry-chunks.md
new file mode 100644
index 00000000..727f0d9f
--- /dev/null
+++ b/.changeset/protect-configured-entry-chunks.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+Never strip `isEntry` from a genuine configured entry when reclassifying emitted lazy facade chunks. The normalization used to demote every chunk that is a dynamic-import target, which misfires once the real client entry absorbs a module that is also imported dynamically: with Solid 2, `@solidjs/web/frames/client` lazily imports the serialization decoder, so a static import of `@solidjs/web/serialization/decode` anywhere in the client graph merges the decoder into the entry chunk and the entry ends up listing itself under `dynamicImports`. Demoting it left the bundle and `manifest.json` with no entry at all ("No entry file found" in downstream manifest capture such as TanStack Start's). Chunks whose facade matches a configured `build.rollupOptions.input` (or the default `index.html` / the start-mode client entry) now keep `isEntry` in the raw bundle and in `virtual:solid-manifest`, a chunk's dynamic import of itself is ignored, emitted `lazy()` facades are still reclassified, and a demotion the plugin cannot attribute to one of its own emitted chunks is reported with a warning describing the graph shape. The virtual manifest also repairs `isDynamicEntry` on lazy facades, which rolldown drops when syncing `generateBundle` mutations back.
diff --git a/examples/css-matrix/src/entry-client.tsx b/examples/css-matrix/src/entry-client.tsx
index 4e2dcd6a..9f3efbec 100644
--- a/examples/css-matrix/src/entry-client.tsx
+++ b/examples/css-matrix/src/entry-client.tsx
@@ -1,5 +1,18 @@
import { hydrate } from '@solidjs/web';
+// Regression coverage for #342: Solid's frames client lazily imports the
+// serialization decoder (`loadCodec()` → import('@solidjs/web/serialization/
+// decode')), and a static import of that same decoder anywhere in the client
+// graph merges it into the entry chunk — the entry then lists itself under
+// its own dynamicImports. The plugin's lazy-entry normalization must keep
+// this chunk flagged `isEntry` (it is the configured input) rather than
+// reclassify it as an emitted lazy facade. Both are referenced, not called
+// (Vite drops entry exports, so a global keeps the graph edges alive without
+// affecting the page).
+import { getFrameHost } from '@solidjs/web/frames/client';
+import { createJSONDeserializer } from '@solidjs/web/serialization/decode';
import App from './App';
import './entryClient.css';
+(window as any).__decoderProbe = { getFrameHost, createJSONDeserializer };
+
hydrate(() => , document);
diff --git a/examples/css-matrix/test/run.mjs b/examples/css-matrix/test/run.mjs
index 0627500b..88270cd4 100644
--- a/examples/css-matrix/test/run.mjs
+++ b/examples/css-matrix/test/run.mjs
@@ -10,7 +10,7 @@
import { spawn, execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
-import { rmSync } from 'node:fs';
+import { readFileSync, rmSync } from 'node:fs';
const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const CHROME =
@@ -222,6 +222,28 @@ const probeExpr = (selector) =>
`(() => { const el = document.querySelector(${JSON.stringify(selector)});` +
` return el ? getComputedStyle(el).color : null; })()`;
+// The client manifest the plugin bakes into the server bundle
+// (`virtual:solid-manifest`): the object literal assigned right after the
+// module's region marker, brace-matched and evaluated as a literal.
+function extractVirtualManifest(serverBundle) {
+ const region = serverBundle.indexOf('virtual:solid-manifest');
+ const start = region === -1 ? -1 : serverBundle.indexOf('{', region);
+ if (start === -1) return null;
+ let depth = 0;
+ for (let i = start; i < serverBundle.length; i++) {
+ const ch = serverBundle[i];
+ if (ch === '{') depth++;
+ else if (ch === '}' && --depth === 0) {
+ try {
+ return new Function(`return (${serverBundle.slice(start, i + 1)});`)();
+ } catch {
+ return null;
+ }
+ }
+ }
+ return null;
+}
+
// ---------------------------------------------------------------------------
// Assertion collection
// ---------------------------------------------------------------------------
@@ -305,6 +327,78 @@ async function runMode(mode) {
entries.length === 1 && entries[0] === 'src/entry-client.tsx',
`entries: ${entries.join(', ')}`,
);
+
+ // #342: the entry chunk absorbed a module that is also dynamically
+ // imported (src/entry-client.tsx statically imports Solid's
+ // serialization decoder, which frames/client lazily imports), so the
+ // entry lists itself under dynamicImports. That shape must not demote
+ // the genuine entry — in the raw bundle as later plugins see it, in
+ // Vite's manifest.json, and in the plugin's own virtual manifest.
+ const entryRecord = manifest['src/entry-client.tsx'];
+ record(
+ mode,
+ 'ssr',
+ 'entry chunk absorbed a dynamically imported module (self dynamicImport)',
+ !!entryRecord?.dynamicImports?.includes('src/entry-client.tsx'),
+ `dynamicImports: ${JSON.stringify(entryRecord?.dynamicImports)}`,
+ );
+ const bundleChunks = JSON.parse(
+ readFileSync(path.join(exampleDir, 'dist/client/.vite/bundle-chunks.json'), 'utf-8'),
+ );
+ const entryChunk = entryRecord && bundleChunks[entryRecord.file];
+ record(
+ mode,
+ 'ssr',
+ 'self-importing entry chunk keeps isEntry in the bundle (post plugin view)',
+ !!entryChunk &&
+ entryChunk.isEntry === true &&
+ entryChunk.dynamicImports.includes(entryRecord.file),
+ `chunk: ${JSON.stringify(entryChunk)}`,
+ );
+ const bundleEntries = Object.keys(bundleChunks).filter((f) => bundleChunks[f].isEntry);
+ record(
+ mode,
+ 'ssr',
+ 'lazy facade chunks stay demoted in the bundle',
+ bundleEntries.length === 1 &&
+ Object.values(bundleChunks).every(
+ (c) => !c.facadeModuleId?.includes('/src/routes/') || c.isEntry === false,
+ ),
+ `bundle entries: ${bundleEntries.join(', ')}`,
+ );
+ // The virtual manifest baked into the server bundle: what
+ // resolveClientEntry() and renderToStream's asset resolution read.
+ const serverBundle = readFileSync(
+ path.join(exampleDir, 'dist/server/entry-server.js'),
+ 'utf-8',
+ );
+ const virtualManifest = extractVirtualManifest(serverBundle);
+ const virtualEntries = virtualManifest
+ ? Object.keys(virtualManifest).filter((k) => virtualManifest[k]?.isEntry)
+ : [];
+ record(
+ mode,
+ 'ssr',
+ 'virtual:solid-manifest keeps the self-importing entry as its single entry',
+ virtualEntries.length === 1 && virtualEntries[0] === 'src/entry-client.tsx',
+ `virtual manifest entries: ${virtualEntries.join(', ')}`,
+ );
+ // Lazy facades come out of rolldown with neither flag (it only syncs
+ // isEntry back from generateBundle); the virtual manifest must still
+ // classify them as dynamic entries.
+ const lazyKeys = Object.keys(virtualManifest ?? {}).filter((k) =>
+ k.startsWith('src/routes/'),
+ );
+ record(
+ mode,
+ 'ssr',
+ 'virtual:solid-manifest classifies lazy facades as dynamic entries',
+ lazyKeys.length > 0 &&
+ lazyKeys.every(
+ (k) => virtualManifest[k].isDynamicEntry === true && !virtualManifest[k].isEntry,
+ ),
+ `lazy records: ${lazyKeys.map((k) => `${k}=${JSON.stringify(virtualManifest[k])}`).join('; ')}`,
+ );
}
// ---- Phase 2: browser ------------------------------------------------
diff --git a/examples/css-matrix/vite.config.ts b/examples/css-matrix/vite.config.ts
index ec6849be..7a67ca36 100644
--- a/examples/css-matrix/vite.config.ts
+++ b/examples/css-matrix/vite.config.ts
@@ -1,6 +1,36 @@
import { defineConfig, type Plugin } from 'vite';
import solidPlugin from '@solidjs/vite-plugin';
+// Records what a downstream plugin sees in the client bundle after the solid
+// plugin's generateBundle (post order, like TanStack Start's manifest
+// capture): entry classification per chunk, written next to Vite's manifest
+// for test/run.mjs to assert against (#269/#271/#342).
+function bundleChunksProbe(): Plugin {
+ return {
+ name: 'css-matrix:bundle-chunks-probe',
+ apply: 'build',
+ enforce: 'post',
+ generateBundle(_outputOptions, bundle) {
+ if (this.environment.config.consumer !== 'client') return;
+ const chunks: Record = {};
+ for (const [fileName, output] of Object.entries(bundle)) {
+ if (output.type !== 'chunk') continue;
+ chunks[fileName] = {
+ facadeModuleId: output.facadeModuleId,
+ isEntry: output.isEntry,
+ isDynamicEntry: output.isDynamicEntry,
+ dynamicImports: output.dynamicImports,
+ };
+ }
+ this.emitFile({
+ type: 'asset',
+ fileName: '.vite/bundle-chunks.json',
+ source: JSON.stringify(chunks, null, 2),
+ });
+ },
+ };
+}
+
// Virtual CSS modules (CSS with no backing file, e.g. generated styles).
// Handles query suffixes (?direct, ?inline, ?url) the way real plugins must:
// Vite's css pipeline re-requests the module with queries appended.
@@ -26,7 +56,11 @@ function virtualCssPlugin(): Plugin {
}
export default defineConfig({
- plugins: [virtualCssPlugin(), solidPlugin({ compiler: 'native', ssr: true })],
+ plugins: [
+ virtualCssPlugin(),
+ solidPlugin({ compiler: 'native', ssr: true }),
+ bundleChunksProbe(),
+ ],
// TEMPORARY: the workspace links solid-js to a sibling worktree (see
// pnpm-workspace.yaml), which stops Vite from externalizing it in SSR and
// splits it into two instances (bundled app copy vs the one the external
diff --git a/src/index.ts b/src/index.ts
index 1206628a..83bf9fdb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,7 @@ import * as babel from '@babel/core';
import type { TransformOptions as JsxCompilerOptions } from '@solidjs/compiler';
import remapping from '@ampproject/remapping';
import solid from '@solidjs/babel-plugin';
-import { existsSync, readFileSync } from 'fs';
+import { existsSync, readFileSync, realpathSync } from 'fs';
import { mergeAndConcat } from 'merge-anything';
import { createRequire } from 'module';
import {
@@ -554,6 +554,100 @@ function combineSourcemaps(maps: ChainableMap[]) {
return JSON.parse(remapping(chain.reverse() as any, () => null).toString());
}
+function toPosixPath(p: string): string {
+ return p.split(path.sep).join('/');
+}
+
+function tryRealpath(p: string): string | null {
+ try {
+ return realpathSync.native(p);
+ } catch {
+ return null;
+ }
+}
+
+/** The `input` a build environment's config resolves to, in any spelling. */
+function configuredBuildInput(build: any): unknown {
+ if (!build) return undefined;
+ return build.rolldownOptions?.input ?? build.rollupOptions?.input ?? build.lib?.entry;
+}
+
+/**
+ * The genuine entries of a client build, derived from its configured input
+ * (`build.rollupOptions.input` as a string / array / record, or Vite's
+ * default `index.html`). Rollup and rolldown only ever flag two kinds of
+ * chunk `isEntry`: those facades and chunks plugins emit with
+ * `emitFile({ type: 'chunk' })` — so this is exactly the knowledge that
+ * tells a real application entry apart from an emitted lazy facade.
+ *
+ * `moduleIds` — every spelling the entry's facade module id can take: as
+ * written (virtual ids resolve to themselves), resolved against the root
+ * (Vite resolves relative file inputs there), and the real path of either
+ * (Vite's resolver follows symlinks).
+ * `manifestKeys` — the manifest.json keys Vite derives from those facades
+ * (root-relative, `\0` stripped), matching Vite's own `getChunkName`.
+ */
+function resolveConfiguredEntries(input: unknown, root: string) {
+ const raw: string[] =
+ input == null
+ ? ['index.html']
+ : typeof input === 'string'
+ ? [input]
+ : Array.isArray(input)
+ ? input
+ : Object.values(input as Record);
+ const moduleIds = new Set();
+ for (const id of raw) {
+ if (typeof id !== 'string') continue;
+ const clean = id.replace(/\0/g, '');
+ const candidates = [clean, path.resolve(root, clean)];
+ for (const candidate of candidates) {
+ moduleIds.add(candidate);
+ moduleIds.add(toPosixPath(candidate));
+ const real = tryRealpath(candidate);
+ if (real) {
+ moduleIds.add(real);
+ moduleIds.add(toPosixPath(real));
+ }
+ }
+ }
+ const manifestKeys = new Set();
+ for (const id of moduleIds) manifestKeys.add(toPosixPath(path.relative(root, id)));
+ return {
+ moduleIds,
+ manifestKeys,
+ isEntryModule(id: string | null | undefined): boolean {
+ if (!id) return false;
+ const clean = id.replace(/\0/g, '');
+ if (moduleIds.has(clean) || moduleIds.has(toPosixPath(clean))) return true;
+ const real = tryRealpath(clean);
+ return !!real && (moduleIds.has(real) || moduleIds.has(toPosixPath(real)));
+ },
+ };
+}
+
+interface NormalizeLazyEntriesOptions {
+ /**
+ * Is this record a genuine configured entry? Such records keep `isEntry`
+ * no matter what dynamically imports them.
+ */
+ isConfiguredEntry: (key: string, record: any) => boolean;
+ /**
+ * Records already known to be emitted lazy facades (reclassified
+ * explicitly by their emit references); everything else the sweep strips
+ * is reported through `warn` because it could be an entry the input
+ * matching missed.
+ */
+ knownLazyKeys?: Set;
+ warn?: (message: string) => void;
+ /**
+ * Also flag dynamic-import targets that already lost `isEntry` as
+ * `isDynamicEntry` — repairs the flag rolldown drops (see below) on the
+ * serialized manifest.
+ */
+ repairDynamicEntries?: boolean;
+}
+
/**
* Chunks emitted for lazy() targets are marked `isEntry` by Rollup even
* though they are semantically dynamic entries. Reclassify any entry that is
@@ -562,18 +656,62 @@ function combineSourcemaps(maps: ChainableMap[]) {
* the real client entry. Works on both the Vite manifest.json shape and the
* raw Rollup output bundle — both key entries by name and expose
* `dynamicImports` / `isEntry` with the same meaning.
+ *
+ * Being a dynamic-import target alone does not make a chunk a lazy facade,
+ * though: the real client entry becomes one whenever it absorbs a module
+ * that is also dynamically imported somewhere else. Solid 2 produces that
+ * shape on its own — `@solidjs/web/frames/client` lazily imports the
+ * serialization decoder (`loadCodec()`), so a static import of
+ * `@solidjs/web/serialization/decode` anywhere in the client graph merges
+ * the decoder into the entry chunk, and the entry then lists itself (or is
+ * listed by another lazy chunk) under `dynamicImports`. Stripping `isEntry`
+ * there leaves the bundle with no entry at all ("No entry file found"
+ * downstream, e.g. TanStack Start's manifest capture, #342). Genuine
+ * configured entries are therefore never reclassified, and a chunk's
+ * dynamic import of itself is not an edge worth acting on.
+ *
+ * Rolldown caveat: of the flags written here only `isEntry` is synced back
+ * to the native bundle after the hook (rolldown's `update_output_chunk`
+ * copies `code`, `map`, `imports`, `dynamicImports`, `isEntry` and the file
+ * name; `isDynamicEntry` is kept from the original chunk). Later plugins
+ * and Vite's manifest plugin therefore see reclassified facades as neither
+ * entry nor dynamic entry under rolldown. The manifest `load` path repairs
+ * `isDynamicEntry` on the plugin's own manifest module, the one place it
+ * controls end to end.
*/
-function normalizeEmittedLazyEntries(manifest: Record) {
- const dynamicKeys = new Set();
+function normalizeEmittedLazyEntries(
+ manifest: Record,
+ { isConfiguredEntry, knownLazyKeys, warn, repairDynamicEntries }: NormalizeLazyEntriesOptions,
+) {
+ const dynamicKeys = new Map();
for (const key in manifest) {
const imports: string[] | undefined = manifest[key].dynamicImports;
- if (imports) for (const dep of imports) dynamicKeys.add(dep);
+ if (!imports) continue;
+ for (const dep of imports) {
+ // A chunk that absorbed one of its own lazy targets imports itself;
+ // that says nothing about whether it is an entry.
+ if (dep !== key && !dynamicKeys.has(dep)) dynamicKeys.set(dep, key);
+ }
}
- for (const key of dynamicKeys) {
+ for (const [key, importer] of dynamicKeys) {
const entry = manifest[key];
- if (entry && entry.isEntry) {
+ if (!entry || entry.type === 'asset') continue;
+ if (isConfiguredEntry(key, entry)) continue;
+ if (entry.isEntry) {
entry.isEntry = false;
entry.isDynamicEntry = true;
+ if (warn && !knownLazyKeys?.has(key)) {
+ warn(
+ `[@solidjs/vite-plugin] Reclassified the entry chunk "${key}" as a dynamic entry ` +
+ `because "${importer}" dynamically imports it and it does not match a configured ` +
+ `build input. If "${key}" is the application entry, its chunk absorbed a module ` +
+ 'that is also imported dynamically elsewhere (for example a static import of ' +
+ '"@solidjs/web/serialization/decode" alongside Solid\'s own lazy import of it); ' +
+ 'list the entry in `build.rollupOptions.input` so the plugin can recognize it.',
+ );
+ }
+ } else if (repairDynamicEntries && !entry.isDynamicEntry) {
+ entry.isDynamicEntry = true;
}
}
}
@@ -645,6 +783,11 @@ export default function solidPlugin(options: Partial = {}): Plugin[] {
let isSsrBuild = false;
let base = '/';
let clientOutDir: string | null = null;
+ // The client environment's resolved build options, for the configured
+ // entry input. Read off the resolved config so the SSR half of a
+ // two-invocation build (`vite build --ssr`) still knows the client's
+ // entries when it bakes the client manifest in.
+ let clientBuildConfig: any = null;
let solidPkgsConfig: Awaited>;
const tsrxCss = new Map();
@@ -988,6 +1131,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] {
isSsrBuild = !!config.build.ssr;
base = config.base;
projectRoot = config.root;
+ clientBuildConfig = (config as any).environments?.client?.build ?? config.build;
filter = createFilter(options.include, options.exclude, { resolve: projectRoot });
styleFilter = createStyleFilter(projectRoot);
// `components: 'external'` is the acknowledgement that a composing
@@ -1141,7 +1285,28 @@ export default function solidPlugin(options: Partial = {}): Plugin[] {
const manifestPath = clientManifestPath();
if (manifestPath) {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
- normalizeEmittedLazyEntries(manifest);
+ // Manifest records are keyed the way Vite keys entry chunks (the
+ // root-relative facade path, also carried as `src`), so the
+ // configured client inputs identify the genuine entries here too —
+ // independent of `isEntry`, which the serialized manifest may have
+ // lost already (older plugin builds stripped it; see #342).
+ const entries = resolveConfiguredEntries(
+ configuredBuildInput(clientBuildConfig),
+ projectRoot,
+ );
+ const isConfiguredEntry = (key: string, record: any) =>
+ entries.manifestKeys.has(key) ||
+ (typeof record.src === 'string' && entries.manifestKeys.has(record.src));
+ for (const key in manifest) {
+ if (isConfiguredEntry(key, manifest[key]) && manifest[key].file) {
+ manifest[key].isEntry = true;
+ }
+ }
+ normalizeEmittedLazyEntries(manifest, {
+ isConfiguredEntry,
+ warn: (message) => this.warn(message),
+ repairDynamicEntries: true,
+ });
manifest._base = base;
return `export default ${JSON.stringify(manifest)};`;
}
@@ -1159,6 +1324,15 @@ export default function solidPlugin(options: Partial = {}): Plugin[] {
// the bundle don't mistake them for application entries. Must precede
// the client asset map build, which keys off dynamic entries.
if (options.ssr) {
+ // The genuine entries are the configured inputs of this very
+ // environment — the plugin injects the client entry itself in start
+ // mode, and Vite's default is index.html — so their facade chunks
+ // are recognizable regardless of what dynamically imports them.
+ const entries = resolveConfiguredEntries(
+ configuredBuildInput(this.environment?.config?.build ?? clientBuildConfig),
+ projectRoot,
+ );
+ const knownLazyKeys = new Set();
for (const ref of emittedLazyChunkRefs) {
let fileName: string;
try {
@@ -1169,10 +1343,17 @@ export default function solidPlugin(options: Partial = {}): Plugin[] {
}
const chunk = bundle[fileName];
if (!chunk || chunk.type !== 'chunk') continue;
+ // An entry that is also lazily imported stays an entry.
+ if (entries.isEntryModule(chunk.facadeModuleId)) continue;
+ knownLazyKeys.add(fileName);
chunk.isEntry = false;
chunk.isDynamicEntry = true;
}
- normalizeEmittedLazyEntries(bundle);
+ normalizeEmittedLazyEntries(bundle, {
+ isConfiguredEntry: (_key, chunk) => entries.isEntryModule(chunk.facadeModuleId),
+ knownLazyKeys,
+ warn: (message) => this.warn(message),
+ });
}
},