From 1f5f6ca7f9351609396ed499cf43a53acc82e5b5 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 02:28:35 -0700 Subject: [PATCH] Keep genuine configured entries when reclassifying emitted lazy facades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeEmittedLazyEntries demoted every chunk that is a dynamic-import target, so the real client entry lost isEntry whenever it absorbed a module that is also dynamically imported elsewhere. Solid 2 produces that shape on its own: @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 lists itself under its own dynamicImports. Downstream manifest capture (TanStack Start, tanstack/router#8214) then failed with "No entry file found", and under rolldown the chunk ended up neither entry nor dynamic entry because only isEntry is synced back from generateBundle (update_output_chunk keeps the original is_dynamic_entry). Derive the genuine entries from the configured build input (rollupOptions / rolldownOptions input in any spelling, lib entry, or Vite's default index.html — the start-mode virtual client entry included) and never demote their facade chunks, in the raw bundle and in the manifest load path (keyed the way Vite keys entry records, and restoring isEntry that an older build already stripped). Ignore a chunk's dynamic import of itself. Emitted lazy() facades are still reclassified by their emit references; a demotion that matches neither is reported with a warning describing the graph shape. The virtual manifest repairs isDynamicEntry on facades rolldown left flagless. css-matrix now builds the exact graph shape (static decoder import next to frames/client in the entry) and asserts the bundle as a post plugin sees it, .vite/manifest.json, and the baked virtual:solid-manifest. Fixes #342 --- .changeset/protect-configured-entry-chunks.md | 5 + examples/css-matrix/src/entry-client.tsx | 13 ++ examples/css-matrix/test/run.mjs | 96 ++++++++- examples/css-matrix/vite.config.ts | 36 +++- src/index.ts | 197 +++++++++++++++++- 5 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 .changeset/protect-configured-entry-chunks.md 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), + }); } },