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), + }); } },