diff --git a/.changeset/dev-toolbar-inspector.md b/.changeset/dev-toolbar-inspector.md new file mode 100644 index 0000000..23fe6f0 --- /dev/null +++ b/.changeset/dev-toolbar-inspector.md @@ -0,0 +1,17 @@ +--- +'@solidjs/vite-plugin': minor +--- + +New `start.devtools` option: a development toolbar with runtime errors and a +server function inspector, backed by the new optional-peer package +`@solidjs/start-devtools`. By default the toolbar turns on in `vite dev` +whenever the package resolves (install it as a dev dependency) and stays off +otherwise; `start: { devtools: true }` makes the package required (a missing +install becomes an error) and `start: { devtools: false }` opts out entirely. +Generated client entries wrap the app in the toolbar's `DevToolbar` component, +authored client entries get an injected mount import instead, and either way +the wiring is dev-serve-only codegen — production builds and previews contain +none of it. The package itself is resolved from the app graph first and from +the plugin's own location as a fallback, and the virtual toolbar modules +delegate their imports to that captured resolution, so pnpm-isolated installs +work without the package being hoisted to the app root. diff --git a/README.md b/README.md index 7a27d1c..c225564 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,19 @@ same server functions. The object form carries the options (`start: true` is pure sugar for `start: {}` — both mean the identical start mode with defaults, and `false`/absent means off): `app`, `document`, `entryServer`, `entryClient`, -`middleware`, `setup`, `env`, `errorBoundary`, `css`, `external`, all -documented below. +`middleware`, `setup`, `env`, `devtools`, `errorBoundary`, `css`, `external`, +all documented below. + +Install `@solidjs/start-devtools` as a development dependency to add the +development toolbar with runtime errors and server function calls: + +```sh +pnpm add -D @solidjs/start-devtools@next +``` + +Start mode detects the package automatically. Set `start: { devtools: true }` +to require it or `start: { devtools: false }` to disable it. The package is an +optional peer and the toolbar is not included in production builds. ```tsx // src/App.tsx — the entire app: a plain content component diff --git a/examples/start-client/test/run.mjs b/examples/start-client/test/run.mjs index ace4d9a..bbf4a7f 100644 --- a/examples/start-client/test/run.mjs +++ b/examples/start-client/test/run.mjs @@ -245,6 +245,31 @@ async function runBrowserChecks(mode, origin) { `getComputedStyle(document.querySelector("#title")).color === ${JSON.stringify(APP_CSS_COLOR)}`, ), ); + record( + mode, + 'browser', + mode === 'dev' ? 'development toolbar mounted' : 'development toolbar omitted', + mode === 'dev' + ? await cdp.waitFor('document.querySelector("[data-solid-dev-toolbar]")') + : !(await cdp.evalJs('document.querySelector("[data-solid-dev-toolbar]")')), + ); + if (mode === 'dev') { + const message = 'DEV_TOOLBAR_TEST_ERROR'; + await cdp.evalJs( + `window.dispatchEvent(new ErrorEvent("error", { error: new Error(${JSON.stringify(message)}) }))`, + ); + record( + mode, + 'browser', + 'runtime error shown in toolbar', + await cdp.waitFor( + `document.querySelector("[data-solid-error-viewer-error-info-message]")?.textContent === ${JSON.stringify(message)}`, + ), + ); + for (let i = cdp.exceptions.length - 1; i >= 0; i--) { + if (cdp.exceptions[i].includes(message)) cdp.exceptions.splice(i, 1); + } + } // Deep-link boot: the same shell must boot the app on a non-root path. await cdp.send('Page.navigate', { url: origin + '/deep/link' }); @@ -288,6 +313,17 @@ async function devMode() { "entry graph CSS inlined (App.css style tag)", /]*data-vite-dev-id="[^"]*App\.css"/.test(html), ); + const entry = await fetch(origin + '/@id/virtual:solid-ssr-entry-client.tsx').then((res) => + res.text(), + ); + record('dev', 'entry', 'toolbar wraps the generated app', entry.includes('DevToolbar')); + const devtools = await fetch(origin + '/@id/virtual:solid-devtools').then((res) => res.text()); + record( + 'dev', + 'entry', + 'server function observer connected', + devtools.includes('observeServerFunctionCalls'), + ); await runBrowserChecks('dev', origin); diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 218faed..1fc7d61 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -642,7 +642,7 @@ async function runHmrChecks(mode, cdp, origin, { expectCompiler } = {}) { } } -async function runBrowserChecks(mode, origin, { hmr, devCss, expectCompiler } = {}) { +async function runBrowserChecks(mode, origin, { hmr, devCss, expectCompiler, devtools } = {}) { const chrome = startProcess(CHROME, [ '--headless=new', `--remote-debugging-port=${CDP_PORT}`, @@ -743,6 +743,25 @@ async function runBrowserChecks(mode, origin, { hmr, devCss, expectCompiler } = ); } + // `devtools: true` (dev, auto-detected package): the toolbar mounts in + // the live DOM. `devtools: false` (prod): it must not exist. Undefined + // skips the check — other dev-server modes don't re-assert the default. + if (devtools === true) { + record( + mode, + 'devtools', + 'development toolbar mounted in the DOM', + await cdp.waitFor('document.querySelector("[data-solid-dev-toolbar]") !== null'), + ); + } else if (devtools === false) { + record( + mode, + 'devtools', + 'no development toolbar in the DOM', + (await cdp.evalJs('document.querySelector("[data-solid-dev-toolbar]")')) === null, + ); + } + const errs = cdp.exceptions.filter((e) => !/favicon/i.test(e)); record(mode, 'browser', 'no page errors', errs.length === 0, errs.join(' | ')); @@ -872,11 +891,34 @@ async function runDevMode() { !generatedEntry.includes('@solidjs/web/frames'), ); + // Devtools default-on: the workspace's @solidjs/start-devtools install is + // auto-detected, so the generated client entry must wrap the app in the + // toolbar and pull the virtual devtools module (whose transformed source + // wires the server-function observer). + record( + mode, + 'devtools', + 'generated client entry wraps the app in DevToolbar', + generatedEntry.includes('DevToolbar') && generatedEntry.includes('virtual:solid-devtools'), + ); + const devtoolsModule = await (await fetch(origin + '/@id/virtual:solid-devtools')).text(); + record( + mode, + 'devtools', + 'server function observer wired in the devtools module', + devtoolsModule.includes('observeServerFunctionCalls'), + ); + await runHttpChecks(mode, origin); await runLazyAssetChecks(mode, origin, { dev: true }); - await runBrowserChecks(mode, origin, { hmr: true, devCss: true, expectCompiler: 'native' }); + await runBrowserChecks(mode, origin, { + hmr: true, + devCss: true, + expectCompiler: 'native', + devtools: true, + }); // ---- Cold-start dep scan (boundary-guard false positive) ------------- // Counterpart: the ssr example's boundary.mjs proves the guard still @@ -894,6 +936,41 @@ async function runDevMode() { 'dependency pre-bundling wrote its metadata', existsSync(path.join(exampleDir, 'node_modules/.vite/deps/_metadata.json')), ); + + // ---- Devtools opt-out sub-run (SSR_DEVTOOLS=0 → devtools: false) ----- + // The package still resolves, but the option must win: no toolbar wrap in + // the generated entry and the virtual devtools module stays unclaimed. + const offPort = 3176; + const offOrigin = `http://localhost:${offPort}`; + const offServer = startProcess( + 'pnpm', + ['exec', 'vite', '--port', String(offPort), '--strictPort'], + { cwd: exampleDir, env: { ...process.env, SSR_DEVTOOLS: '0' } }, + ); + try { + await waitForHttp(offOrigin + '/src/api.ts', 30000); + const offEntry = await ( + await fetch(offOrigin + '/@id/virtual:solid-ssr-entry-client.tsx') + ).text(); + record( + mode, + 'devtools', + 'devtools: false strips the toolbar from the generated entry', + !offEntry.includes('DevToolbar') && !offEntry.includes('virtual:solid-devtools'), + ); + const offModule = await fetch(offOrigin + '/@id/virtual:solid-devtools'); + record( + mode, + 'devtools', + 'devtools: false leaves the virtual devtools module unserved', + !offModule.ok, + `status ${offModule.status}`, + ); + } finally { + try { + process.kill(-offServer.pid, 'SIGTERM'); + } catch {} + } } catch (e) { record( mode, @@ -1001,6 +1078,27 @@ async function runProdMode() { 'no server-components transform in server bundle (option off)', !serverBundle.includes('@solidjs/web/frames') && !serverBundle.includes('frameTransformResult'), ); + // Dev-serve-only guarantee for `start.devtools`: production output carries + // none of it — client assets checked via the toolbar's minification-proof + // DOM marker and the package name, the (unminified) server bundle via the + // package name and the virtual module id. + const devtoolsLeaks = readdirSync(assetsDir).filter((f) => { + const source = readFileSync(path.join(assetsDir, f), 'utf-8'); + return source.includes('data-solid-dev-toolbar') || source.includes('start-devtools'); + }); + record( + mode, + 'dce', + 'no devtools code in client assets', + devtoolsLeaks.length === 0, + devtoolsLeaks.join(', '), + ); + record( + mode, + 'dce', + 'no devtools wiring in server bundle', + !serverBundle.includes('start-devtools') && !serverBundle.includes('virtual:solid-devtools'), + ); const server = startProcess('node', ['server.js'], { cwd: exampleDir, @@ -1076,7 +1174,7 @@ async function runProdMode() { await runLazyAssetChecks(mode, origin, { dev: false }); - await runBrowserChecks(mode, origin); + await runBrowserChecks(mode, origin, { devtools: false }); } catch (e) { record( mode, diff --git a/examples/start-ssr/vite.config.ts b/examples/start-ssr/vite.config.ts index d2ab6d4..00aefd9 100644 --- a/examples/start-ssr/vite.config.ts +++ b/examples/start-ssr/vite.config.ts @@ -30,6 +30,9 @@ import solidPlugin from '@solidjs/vite-plugin'; // - SERVER_FN_DEV_MIDDLEWARE=0 disables the built-in dev middleware via // `serverFunctions.devMiddleware` (no-middleware mode) — endpoint dispatch // becomes the host's job, like a Cloudflare-style environment plugin. +// - SSR_DEVTOOLS=0 disables the development toolbar via `start.devtools` +// (dev-mode off sub-run); by default the workspace's @solidjs/start-devtools +// install is auto-detected and the toolbar mounts in dev. // - BUILD_SSR_FIRST installs an adversarial `builder.buildApp` that builds // the ssr environment before the client (builder-order mode) — mimicking // host orchestrators like @cloudflare/vite-plugin; the plugin's @@ -137,6 +140,11 @@ export default defineConfig({ ? { css: { filter: { include: /App\.tsx$/, exclude: /App\.tsx$/ } } } : {}), ...(process.env.CSS_FILTER === 'default' ? { app: 'src/CssLibApp.tsx' } : {}), + // SSR_DEVTOOLS=0 (dev-mode sub-run) opts out of the development + // toolbar via `start.devtools`. Without the knob the workspace's + // @solidjs/start-devtools install is auto-detected, so plain dev + // runs double as coverage for the default-on wiring. + ...(process.env.SSR_DEVTOOLS === '0' ? { devtools: false } : {}), // SSR_MIDDLEWARE=1 (middleware/preview modes): a fetch-style // chain fronting every dispatch path — page SSR, /_server, // preview — with getRequestEvent() live inside it. diff --git a/package.json b/package.json index b7ab1b3..28ae71e 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^15.2.3", "@skypack/package-check": "^0.2.2", + "@solidjs/start-devtools": "^1.0.0-next.0", "@types/node": "^18.18.4", "cypress": "^14.0.0", "cypress-visual-regression": "^5.2.2", @@ -85,12 +86,16 @@ "vite": "^7.0.0" }, "peerDependencies": { + "@solidjs/start-devtools": "^1.0.0-next.0", "@solidjs/web": "^2.0.0-rc.0", "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^2.0.0-rc.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" }, "peerDependenciesMeta": { + "@solidjs/start-devtools": { + "optional": true + }, "@testing-library/jest-dom": { "optional": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ffc822..1723491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@skypack/package-check': specifier: ^0.2.2 version: 0.2.2 + '@solidjs/start-devtools': + specifier: ^1.0.0-next.0 + version: 1.0.0-next.0(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0) '@types/node': specifier: ^18.18.4 version: 18.19.130 @@ -1636,6 +1639,12 @@ packages: '@solidjs/signals@2.0.0-rc.0': resolution: {integrity: sha512-oKZSfvsCcKw1uJjOGbUkJ+OqlhXLHtZ+rShSyu9KH0lUH7UUwfMfsKeh81JPiQxDDg4YLhEwI38hg0JkwzTdvA==} + '@solidjs/start-devtools@1.0.0-next.0': + resolution: {integrity: sha512-rcmAl7j3Bni4c8tICQhecF8Pgi8vebmR5XzBLhadv0WznDNdSZ5uRAuuaSCnk/GOa+y9wR/mstbnoxao47Ssng==} + peerDependencies: + '@solidjs/web': ^2.0.0-rc.0 + solid-js: ^2.0.0-rc.0 + '@solidjs/testing-library@1.0.0-beta.2': resolution: {integrity: sha512-TLhQ5IUT/fdDfqa4X2rkQWB28Y+zEwi6mK/TVTeiQlEHG63eK2jfgwNYf2NtQoPh2c3ihLilsCzxABiSTP3JoQ==} engines: {node: '>= 14'} @@ -4891,6 +4900,11 @@ snapshots: '@solidjs/signals@2.0.0-rc.0': {} + '@solidjs/start-devtools@1.0.0-next.0(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0)': + dependencies: + '@solidjs/web': 2.0.0-rc.0(solid-js@2.0.0-rc.0) + solid-js: 2.0.0-rc.0 + '@solidjs/testing-library@1.0.0-beta.2(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0)': dependencies: '@solidjs/web': 2.0.0-rc.0(solid-js@2.0.0-rc.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df95e82..252112a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ ignoredBuiltDependencies: minimumReleaseAgeExclude: - '@solidjs/signals@2.0.0-beta.30 || 2.0.0-beta.31 || 2.0.0-beta.32 || 2.0.0-rc.0' + - '@solidjs/start-devtools@1.0.0-next.0' - '@solidjs/web@2.0.0-beta.30 || 2.0.0-beta.31 || 2.0.0-beta.32 || 2.0.0-rc.0' - babel-preset-solid@2.0.0-beta.30 || 2.0.0-beta.31 || 2.0.0-beta.32 || 2.0.0-rc.0 - solid-js@2.0.0-beta.30 || 2.0.0-beta.31 || 2.0.0-beta.32 || 2.0.0-rc.0 diff --git a/rollup.config.js b/rollup.config.js index 6d697f0..2d4c9b5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -13,7 +13,7 @@ const external = [ 'babel-preset-solid', 'merge-anything', 'vitefu', - 'vite' + 'vite', ]; /** diff --git a/src/devtools/index.ts b/src/devtools/index.ts new file mode 100644 index 0000000..48c58e8 --- /dev/null +++ b/src/devtools/index.ts @@ -0,0 +1,21 @@ +export const DEVTOOLS_PACKAGE = '@solidjs/start-devtools'; +export const DEVTOOLS_ID = 'virtual:solid-devtools'; +export const DEVTOOLS_MOUNT_ID = 'virtual:solid-devtools/mount'; + +export function devtoolsModuleCode(): string { + return [ + `import * as serverFunctions from '@solidjs/web/server-functions';`, + `import { DevToolbar, pushServerFunctionCall } from '${DEVTOOLS_PACKAGE}';`, + `const observe = Reflect.get(serverFunctions, 'observeServerFunctionCalls');`, + `if (typeof observe === 'function') observe(pushServerFunctionCall);`, + `export { DevToolbar };`, + ].join('\n'); +} + +export function devtoolsMountModuleCode(): string { + return [ + `import ${JSON.stringify(DEVTOOLS_ID)};`, + `import { mountDevToolbar } from '${DEVTOOLS_PACKAGE}';`, + `mountDevToolbar();`, + ].join('\n'); +} diff --git a/src/ssr/index.ts b/src/ssr/index.ts index ad6e90f..fd27fbb 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -44,15 +44,23 @@ // endpoint. Client code compiles non-hydratable, exactly like a plain SPA. import { existsSync, rmSync, writeFileSync } from 'fs'; import path from 'path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { type DevEnvironment, type FilterPattern, + normalizePath, type Plugin, type PreviewServer, type ViteDevServer, } from 'vite'; import { getEnvironmentConsumer, isRunnableEnvironment } from '../environment.js'; +import { + DEVTOOLS_ID, + DEVTOOLS_MOUNT_ID, + DEVTOOLS_PACKAGE, + devtoolsModuleCode, + devtoolsMountModuleCode, +} from '../devtools/index.js'; import { collectDevStyles, collectDevStyleSources, @@ -218,6 +226,14 @@ export interface StartOptions { * @default undefined (probe env.ts / env.js; off when absent) */ env?: boolean | string; + /** + * Enable the development toolbar. By default it is enabled when + * `@solidjs/start-devtools` is installed. Setting this to `true` requires + * the package, while `false` disables it. + * + * @default undefined + */ + devtools?: boolean; /** * Add the default production error boundary to generated entries. * Disable this when application middleware owns error handling. Authored @@ -442,6 +458,10 @@ export function startServe( const serverComponents = !!internal.serverComponents; const errorBoundary = options.errorBoundary !== false; const styleFilter = internal.styleFilter; + let devtools: boolean | undefined = false; + let devtoolsResolution: Promise | undefined; + /** Resolved module id of `@solidjs/start-devtools` once detection succeeds. */ + let devtoolsId: string | null = null; // `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; @@ -460,6 +480,51 @@ export function startServe( return entries; } + async function resolveDevtools( + resolve: (source: string, importer: string) => Promise<{ id: string } | null>, + importer: string, + ): Promise { + if (devtools !== undefined) return devtools; + // 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 the virtual modules' imports of the package can + // be delegated to it (see resolveId). + devtoolsResolution ??= (async () => + ( + (await resolve(DEVTOOLS_PACKAGE, importer)) ?? + (await resolve(DEVTOOLS_PACKAGE, fileURLToPath(import.meta.url))) + )?.id ?? null)(); + devtoolsId = await devtoolsResolution; + devtools = devtoolsId !== null; + if (!devtools && 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 devtools; + } + + /** + * Cheap root-walk probe mirroring how the optimizer resolves bare + * `optimizeDeps.include` entries: is @solidjs/start-devtools reachable from + * the Vite root? Detection proper (resolveDevtools) runs later with a real + * importer; this only decides whether the toolbar graph can be pre-bundled + * at scan time — it hangs off virtual modules the scanner never sees, so + * first-request discovery would force a re-optimize + full page reload. + */ + function devtoolsReachableFromRoot(dir: string): boolean { + for (let current = dir; ; ) { + if (existsSync(path.join(current, 'node_modules', DEVTOOLS_PACKAGE, 'package.json'))) { + return true; + } + const parent = path.dirname(current); + if (parent === current) return false; + current = parent; + } + } + /** Import specifier for generated code: absolute for files, id for virtuals. */ function entryServerSpec(): string { const { entryServer } = requireEntries(); @@ -528,18 +593,19 @@ export function startServe( : []; } - function documentTree(root: string): string[] { + function documentTree(root: string, wrapper?: string): string[] { + const content = wrapper ? `<${wrapper}><${root} />` : `<${root} />`; return isBuild && errorBoundary ? [ ` `, ` `, ` `, - ` <${root} />`, + ` ${content}`, ` `, ` `, ` `, ] - : [` `, ` <${root} />`, ` `]; + : [` `, ` ${content}`, ` `]; } function generatedEntryServerCode(): string { @@ -636,7 +702,7 @@ export function startServe( ].join('\n'); } - function generatedEntryClientCode(): string { + function generatedEntryClientCode(toolbar: boolean): string { const { app } = requireEntries(); if (clientMode) { // render(), not hydrate(): the shell's body is empty, the app mounts @@ -647,17 +713,21 @@ export function startServe( return [ `import { render } from '@solidjs/web';`, ...errorBoundaryImport(), + ...(toolbar ? [`import { DevToolbar } from ${JSON.stringify(DEVTOOLS_ID)};`] : []), `import App from ${JSON.stringify(app)};`, ``, `render(() => ${ isBuild && errorBoundary ? '' - : '' + : toolbar + ? '' + : '' }, document.body);`, ].join('\n'); } return [ `import { hydrate } from '@solidjs/web';`, + ...(toolbar ? [`import { DevToolbar } from ${JSON.stringify(DEVTOOLS_ID)};`] : []), ...(serverComponents ? [`import { installServerComponents } from '@solidjs/web/frames';`] : []), @@ -675,7 +745,7 @@ export function startServe( ] : []), `hydrate(() => (`, - ...documentTree('App'), + ...documentTree('App', toolbar ? 'DevToolbar' : undefined), `), document);`, ].join('\n'); } @@ -1001,6 +1071,12 @@ export function startServe( enforce: 'pre', config(userConfig, env) { root = path.resolve(userConfig.root || process.cwd()); + devtools = + env.command === 'serve' && !env.isPreview && options.devtools !== false + ? undefined + : false; + devtoolsResolution = undefined; + devtoolsId = null; entries = resolveEntries(root, options, clientMode); middlewarePath = options.middleware ? path.resolve(root, normalizeUserPath(root, options.middleware, 'middleware')) @@ -1117,7 +1193,17 @@ export function startServe( }, } : {}), - optimizeDeps: { entries: scanEntries }, + optimizeDeps: { + entries: scanEntries, + // Like the refresh runtime in the main plugin: the toolbar + // graph is injected behind virtual modules the scanner never + // crawls, so pre-bundle it (and the server-functions runtime + // the virtual module pulls in) up front — first-request + // discovery would re-optimize and full-reload the page. + ...(devtools === undefined && devtoolsReachableFromRoot(root) + ? { include: [DEVTOOLS_PACKAGE, '@solidjs/web/server-functions'] } + : {}), + }, }), }; }, @@ -1126,7 +1212,7 @@ export function startServe( base = config.base; isBuild = config.command === 'build'; }, - resolveId(source) { + resolveId(source, importer) { if (source === HANDLER_ID) { return { id: HANDLER_ID, moduleSideEffects: true }; } @@ -1141,6 +1227,21 @@ export function startServe( ) { return { id: source, moduleSideEffects: source === ENTRY_CLIENT_ID }; } + if (devtools && (source === DEVTOOLS_ID || source === DEVTOOLS_MOUNT_ID)) { + return { id: source, moduleSideEffects: true }; + } + // The virtual devtools modules import the package by its bare name, + // but a virtual importer gives Vite no directory to walk, so the + // specifier would only resolve from the Vite root — which fails in + // pnpm-isolated apps where the package is not a root-level install. + // Delegate to the resolution captured at detection time instead. + if ( + devtoolsId && + source === DEVTOOLS_PACKAGE && + (importer === DEVTOOLS_ID || importer === DEVTOOLS_MOUNT_ID) + ) { + return { id: devtoolsId }; + } return null; }, async load(id, opts) { @@ -1162,11 +1263,54 @@ export function startServe( return devStylesModuleCode(this.environment, (file) => this.addWatchFile(file)); } if (id === ENTRY_SERVER_ID) return generatedEntryServerCode(); - if (id === ENTRY_CLIENT_ID) return generatedEntryClientCode(); + if (id === ENTRY_CLIENT_ID) { + const toolbar = await resolveDevtools( + (source, importer) => this.resolve(source, importer, { skipSelf: true }), + requireEntries().app!, + ); + return generatedEntryClientCode(toolbar); + } if (id === DOCUMENT_ID) return documentShellCode; if (id === ERROR_BOUNDARY_ID) return errorBoundaryCode; + if (id === DEVTOOLS_ID || id === DEVTOOLS_MOUNT_ID) { + // A cold direct request (stale tab reload) can reach the virtual + // module before the entry has triggered detection — run it here so + // first-touch order doesn't matter. + if (!isBuild && consumer === 'client' && devtools === undefined) { + const { app, entryClient } = requireEntries(); + await resolveDevtools( + (source, importer) => this.resolve(source, importer, { skipSelf: true }), + app ?? path.resolve(root, entryClient), + ); + } + if (isBuild || !devtools || consumer !== 'client') { + this.error(`${id} is only available to the development client.`); + } + return id === DEVTOOLS_ID ? devtoolsModuleCode() : devtoolsMountModuleCode(); + } return null; }, + async transform(code, id, opts) { + if (isBuild || devtools === false) return null; + const current = requireEntries(); + if (current.generated || getEnvironmentConsumer(this.environment, opts) !== 'client') { + return null; + } + // Module ids are always forward-slashed; normalize the path.resolve + // side too so the comparison holds on Windows. + if (normalizePath(id.split('?')[0]) !== normalizePath(path.resolve(root, current.entryClient))) { + return null; + } + const toolbar = await resolveDevtools( + (source, importer) => this.resolve(source, importer, { skipSelf: true }), + id, + ); + if (!toolbar) return null; + return { + code: `import ${JSON.stringify(DEVTOOLS_MOUNT_ID)};\n${code}`, + map: null, + }; + }, configurePreviewServer(server: PreviewServer) { // `vite build && vite preview` runs the production artifact as-is: // Vite's preview statics serve dist/client (see the config hook) and diff --git a/tsconfig.json b/tsconfig.json index 70191cb..3f560e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,4 +19,4 @@ "types":["cypress", "node"], "baseUrl": "." } -} \ No newline at end of file +}