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
7 changes: 7 additions & 0 deletions .changeset/devtools-optimizer-hash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@solidjs/vite-plugin': patch
---

Fix `vite dev` breaking after a mid-session dependency re-optimization when the development toolbar is installed. The generated entries' `@solidjs/start-devtools` import reused the id captured when the toolbar was detected; in the client environment that id is the optimizer's pre-bundled URL, stamped with the browserHash of the pass that produced it. Any dependency discovered after the initial scan re-optimizes — the toolbar's chunks are re-emitted under new names and the hash moves on — and the frozen id kept the entry on the previous pass: its lazy chunks answered `504 Outdated Optimize Dep` and the stale bundle brought a second `solid-js` instance into the page (hydration key misses, `REACTIVITY_HALTED`). The import is now resolved afresh on every request, so it always follows the current optimizer pass.

The most common trigger is also removed: the agent diagnostics bridge (`@solidjs/diagnostics/browser` and `/protocol`) reaches the page through a virtual module the dependency scanner never crawls, so its first load discovered the two imports and forced exactly that re-optimize + reload. The diagnostics plugin now pre-bundles them up front whenever the surface is enabled.
56 changes: 56 additions & 0 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,62 @@ async function runDevMode() {
ssrError: true,
});

// ---- Re-optimization keeps the toolbar import current ----------------
// The generated entry's toolbar import resolves through the optimizer,
// so it carries a browserHash. A dependency discovered after the initial
// scan re-optimizes (new hash, toolbar chunks re-emitted under new
// names); the entry must follow, not keep the id detection resolved
// against the first pass — that stale id 504'd the toolbar's lazy chunks
// and brought a second solid-js instance into the page. The probe module
// imports a package subpath the app graph never touches, which is what
// a late discovery looks like — one that shares chunks with the deps
// already bundled, so the optimizer has to rewrite them (a dependency
// that only adds a standalone bundle keeps the old hashes and reloads
// nothing).
{
const entryUrl = origin + '/@id/virtual:solid-ssr-entry-client.tsx';
const hashes = async () => {
const entry = await (await fetch(entryUrl)).text();
return {
toolbar: /@solidjs_start-devtools\.js\?v=([0-9a-f]+)/.exec(entry)?.[1] ?? null,
web: /@solidjs_web\.js\?v=([0-9a-f]+)/.exec(entry)?.[1] ?? null,
};
};
const before = await hashes();
const probePath = path.join(exampleDir, 'src/ReoptimizeProbe.ts');
writeFileSync(
probePath,
`import '@solidjs/web/frames/client';\nexport const probe = true;\n`,
);
let after = before;
try {
await fetch(origin + '/src/ReoptimizeProbe.ts');
// Discovery re-optimizes on a short debounce; wait for the entry's
// other optimized import to move to the new pass.
const deadline = Date.now() + 15000;
while (after.web === before.web && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
after = await hashes();
}
} finally {
rmSync(probePath, { force: true });
}
record(
mode,
'devtools',
'late dependency discovery re-optimized (probe)',
!!before.web && !!after.web && after.web !== before.web,
`@solidjs/web ?v=${before.web} -> ?v=${after.web}`,
);
record(
mode,
'devtools',
'toolbar import follows the optimizer across a re-optimization',
!!after.toolbar && after.toolbar === after.web && after.toolbar !== before.toolbar,
`start-devtools ?v=${before.toolbar} -> ?v=${after.toolbar}, @solidjs/web ?v=${after.web}`,
);
}

// ---- Cold-start dep scan (boundary-guard false positive) -------------
// Counterpart: the ssr example's boundary.mjs proves the guard still
// errors on real client-graph imports of 'server-only'.
Expand Down
17 changes: 17 additions & 0 deletions src/diagnostics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,23 @@ export function solidDiagnostics(mode: true | 'auto' = 'auto'): Plugin {
return env.command === 'serve' && !env.isPreview && env.mode !== 'test';
},

// The bridge module is virtual and reaches the page behind the
// scanner's back (a script injected into index.html at transform time,
// or an import the generated start entry adds), so the optimizer never
// sees its two package imports up front. Pre-bundle them: otherwise the
// first page load discovers them, re-optimizes and full-reloads — a
// flash at best, and a broken page whenever anything else on the page
// was resolved against the first optimizer pass.
config(userConfig) {
const rootDir = path.resolve(userConfig.root || process.cwd());
if (mode !== true && !detectDiagnosticsPackage(rootDir)) return;
return {
optimizeDeps: {
include: [`${DIAGNOSTICS_PACKAGE}/browser`, `${DIAGNOSTICS_PACKAGE}/protocol`],
},
};
},

configResolved(config) {
root = config.root;
base = config.base;
Expand Down
91 changes: 58 additions & 33 deletions src/ssr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,10 +552,11 @@ export function startServe(
// any of the (lazy) uses in entry codegen and the entry transform.
let diagnostics = internal.diagnostics === true;
let devtoolsEnabled = false;
let devtoolsResolutions: Partial<
Record<'client' | 'server', Promise<string | null>>
> = {};
let devtoolsIds: Partial<Record<'client' | 'server', string | null>> = {};
// Toolbar detection, memoized per consumer: whether @solidjs/start-devtools
// resolves at all, and the app importer it was probed from. Only the
// verdict is kept — the resolved id is deliberately not (see resolveId).
let devtoolsDetections: Partial<Record<'client' | 'server', Promise<boolean>>> = {};
let devtoolsImporters: Partial<Record<'client' | 'server', string>> = {};
// `external` is server-mode-only (documented no-op in client mode, so a
// host-integrated config survives the `ssr` boolean flip untouched).
const externalServer = !clientMode && !!options.external;
Expand All @@ -582,37 +583,49 @@ export function startServe(
return entries;
}

type DevtoolsResolve = (source: string, importer: string) => Promise<{ id: string } | null>;

/**
* Resolve @solidjs/start-devtools for generated code: from the app graph
* first (the documented install location), then from the plugin's own
* file — in pnpm-isolated apps a copy that is only a dependency of the
* plugin is not reachable from the app's importers. Resolving from the
* plugin's own file never yields null when the package is absent: it is
* declared an optional peer dependency, so Vite answers with its
* `__vite-optional-peer-dep:` stub (an empty module). That stub counts as
* "not installed".
*/
async function resolveDevtoolsId(
resolve: DevtoolsResolve,
importer: string,
): Promise<string | null> {
const realId = (resolved: { id: string } | null) =>
resolved && !resolved.id.startsWith('__vite-optional-peer-dep:') ? resolved.id : null;
return (
realId(await resolve(DEVTOOLS_PACKAGE, importer)) ??
realId(await resolve(DEVTOOLS_PACKAGE, fileURLToPath(import.meta.url)))
);
}

async function resolveDevtools(
resolve: (source: string, importer: string) => Promise<{ id: string } | null>,
resolve: DevtoolsResolve,
importer: string,
consumer: 'client' | 'server',
): Promise<boolean> {
if (!devtoolsEnabled) return false;
// Detect from the app graph first (the documented install location), then
// from the plugin's own file: in pnpm-isolated apps a copy that is only a
// dependency of the plugin is not reachable from the app's importers. The
// resolved id is kept so imports from generated modules can use it.
devtoolsResolutions[consumer] ??= (async () => {
// Resolving from the plugin's own file never yields null when the
// package is absent: it is declared an optional peer dependency, so
// Vite answers with its `__vite-optional-peer-dep:` stub (an empty
// module). Treat that stub as "not installed".
const realId = (resolved: { id: string } | null) =>
resolved && !resolved.id.startsWith('__vite-optional-peer-dep:') ? resolved.id : null;
return (
realId(await resolve(DEVTOOLS_PACKAGE, importer)) ??
realId(await resolve(DEVTOOLS_PACKAGE, fileURLToPath(import.meta.url)))
);
})();
const id = await devtoolsResolutions[consumer];
devtoolsIds[consumer] = id;
if (!id && options.devtools === true) {
// An install cannot change under a running server, so the verdict is
// memoized per consumer. The id it was reached with is not reused for
// generated imports: resolveId resolves afresh from the same importer.
devtoolsImporters[consumer] ??= importer;
devtoolsDetections[consumer] ??= resolveDevtoolsId(resolve, importer).then((id) => id !== null);
const detected = await devtoolsDetections[consumer];
if (!detected && options.devtools === true) {
throw new Error(
'[@solidjs/vite-plugin] start.devtools requires @solidjs/start-devtools. ' +
'Install it as a development dependency or set start.devtools to false.',
);
}
return id !== null;
return detected;
}

/**
Expand Down Expand Up @@ -1267,8 +1280,8 @@ export function startServe(
root = path.resolve(userConfig.root || process.cwd());
devtoolsEnabled =
env.command === 'serve' && !env.isPreview && options.devtools !== false;
devtoolsResolutions = {};
devtoolsIds = {};
devtoolsDetections = {};
devtoolsImporters = {};
entries = resolveEntries(root, options, clientMode);
internal.onDocumentResolved?.(entries.document);
middlewarePath = options.middleware
Expand Down Expand Up @@ -1429,7 +1442,7 @@ export function startServe(
diagnostics = detectDiagnosticsPackage(root);
}
},
resolveId(source, importer, opts) {
async resolveId(source, importer, opts) {
if (source === HANDLER_ID) {
return { id: HANDLER_ID, moduleSideEffects: true };
}
Expand All @@ -1447,17 +1460,29 @@ export function startServe(
if (devtoolsEnabled && source === DEVTOOLS_MOUNT_ID) {
return { id: source, moduleSideEffects: true };
}
// Generated modules have no directory for bare-package resolution.
// Reuse the app-relative id captured during detection.
const devtoolsId = devtoolsIds[getEnvironmentConsumer(this.environment, opts)];
if (
devtoolsId &&
source === DEVTOOLS_PACKAGE &&
(importer === ENTRY_SERVER_ID ||
importer === ENTRY_CLIENT_ID ||
importer === DEVTOOLS_MOUNT_ID)
) {
return { id: devtoolsId };
// Generated modules have no directory for bare-package resolution:
// resolve from the app importer detection probed. Resolve afresh on
// every request rather than reusing detection's id — in the client
// environment that id is the optimizer's pre-bundled URL, stamped
// with the browserHash of the pass that produced it. Any dependency
// discovered after the initial scan re-optimizes: the toolbar's
// chunks are re-emitted under new names and the hash moves on, and
// a frozen id would keep the entry on the previous pass — its lazy
// chunks answer 504 (Outdated Optimize Dep) and the stale bundle
// brings a second solid-js instance into the page.
const from = devtoolsImporters[getEnvironmentConsumer(this.environment, opts)];
if (!from) return null;
const id = await resolveDevtoolsId(
(s, i) => this.resolve(s, i, { skipSelf: true }),
from,
);
return id ? { id } : null;
}
return null;
},
Expand Down
Loading