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/protect-configured-entry-chunks.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions examples/css-matrix/src/entry-client.tsx
Original file line number Diff line number Diff line change
@@ -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(() => <App url={location.pathname} />, document);
96 changes: 95 additions & 1 deletion examples/css-matrix/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 ------------------------------------------------
Expand Down
36 changes: 35 additions & 1 deletion examples/css-matrix/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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.
Expand All @@ -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
Expand Down
Loading
Loading