From a3cc78251c0459efde050207dbc0821003f72c69 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Mon, 7 Sep 2026 21:42:09 -0500 Subject: [PATCH] Resolve the devtools import afresh after re-optimization; pre-bundle the diagnostics bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Solid 2 projects created with the development toolbar broke under `vite dev` on first load: the toolbar's lazy chunks answered `504 Outdated Optimize Dep`, a second solid-js instance entered the page, and hydration failed (REACTIVITY_HALTED). 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. resolveId now resolves the package afresh on every request, from the app importer detection probed; detection itself stays memoized, verdict only. The trigger every fresh template project hit is removed too: the agent diagnostics bridge reaches the page through a virtual module the scanner never crawls, so its two imports (`@solidjs/diagnostics/browser` and `/protocol`) were only discovered on the first page load, forcing exactly that re-optimize + reload. The diagnostics plugin pre-bundles them whenever the surface is enabled. The start-ssr dev harness gains a regression check: a probe import forces a reload-class re-optimization and the generated entry's toolbar import must move to the same hash as its `@solidjs/web` import. It fails on the previous code (66/67) and passes with this change (67/67). --- .changeset/devtools-optimizer-hash.md | 7 +++ examples/start-ssr/test/run.mjs | 56 +++++++++++++++++ src/diagnostics/index.ts | 17 +++++ src/ssr/index.ts | 91 +++++++++++++++++---------- 4 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 .changeset/devtools-optimizer-hash.md diff --git a/.changeset/devtools-optimizer-hash.md b/.changeset/devtools-optimizer-hash.md new file mode 100644 index 0000000..5801515 --- /dev/null +++ b/.changeset/devtools-optimizer-hash.md @@ -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. diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index c924ef2..cde93ec 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -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'. diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts index 80f9c0e..905ec86 100644 --- a/src/diagnostics/index.ts +++ b/src/diagnostics/index.ts @@ -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; diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 90f0005..c62f343 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -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> - > = {}; - let devtoolsIds: Partial> = {}; + // 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>> = {}; + let devtoolsImporters: Partial> = {}; // `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; @@ -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 { + 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 { 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; } /** @@ -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 @@ -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 }; } @@ -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; },