diff --git a/.changeset/vite-plugin.md b/.changeset/vite-plugin.md new file mode 100644 index 0000000..1ef08fe --- /dev/null +++ b/.changeset/vite-plugin.md @@ -0,0 +1,5 @@ +--- +'@solidjs/start-devtools': minor +--- + +Add a Vite plugin at `@solidjs/start-devtools/vite`. In an app served from an `index.html`, it mounts the toolbar during `vite dev`, so the app needs no JSX for it. Builds, `vite preview` and Vitest are left alone. diff --git a/README.md b/README.md index 0c127d1..1d88a93 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,27 @@ Development error and server-function tooling for Solid Start mode. pnpm add @solidjs/start-devtools@next ``` -The Vite plugin mounts it automatically. Integrations can use the package directly: +In Start mode, `@solidjs/vite-plugin` mounts it automatically. + +For an app served from an `index.html`, add the devtools' own Vite plugin. It mounts the +toolbar beside the app during `vite dev`, so the app needs no JSX for it. Builds, +`vite preview` and Vitest are left alone. + +```ts +// vite.config.ts +import solid from '@solidjs/vite-plugin'; +import devtools from '@solidjs/start-devtools/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [solid(), devtools()], +}); +``` + +The toolbar never mounts twice, so the plugin is safe in an app that also renders +`DevToolbar`. Pass `devtools({ mount: false })` to keep the plugin without the mount. + +Integrations can use the package directly: ```tsx import { DevToolbar } from '@solidjs/start-devtools'; diff --git a/package.json b/package.json index fd337fe..fac06a3 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "import": "./dist/index.js" }, "default": "./dist/noop.js" + }, + "./vite": { + "types": "./dist/types/vite.d.ts", + "default": "./dist/vite.js" } }, "scripts": { @@ -53,7 +57,13 @@ }, "peerDependencies": { "@solidjs/web": "^2.0.0-rc.13", - "solid-js": "^2.0.0-rc.13" + "solid-js": "^2.0.0-rc.13", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", diff --git a/playwright.config.ts b/playwright.config.ts index 5da3f47..36d57ff 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,9 +16,18 @@ export default defineConfig({ baseURL: 'http://127.0.0.1:4173', trace: 'retain-on-failure', }, - webServer: { - command: 'pnpm exec vite --config tests/fixture/vite.config.ts --host 127.0.0.1 --port 4173', - url: 'http://127.0.0.1:4173', - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'pnpm exec vite --config tests/fixture/vite.config.ts --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, + // An app that renders no toolbar itself, for the Vite plugin. + { + command: + 'pnpm exec vite --config tests/fixture-vite/vite.config.ts --host 127.0.0.1 --port 4174', + url: 'http://127.0.0.1:4174', + reuseExistingServer: !process.env.CI, + }, + ], }); diff --git a/rolldown.config.js b/rolldown.config.js index 9f834af..877ef8e 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -124,4 +124,26 @@ export default defineConfig([ external, plugins: [dts({ emitDtsOnly: true })], }, + // The Vite plugin runs in Node, in the app's Vite config, so it keeps Vite + // and Node's modules external. + { + input: 'src/vite.ts', + platform: 'node', + output: { + format: 'esm', + dir: 'dist', + entryFileNames: 'vite.js', + sourcemap: true, + }, + external: (id) => id === 'vite' || id.startsWith('node:'), + }, + { + input: 'src/vite.ts', + output: { + format: 'esm', + dir: 'dist/types', + }, + external: (id) => id === 'vite' || id.startsWith('node:'), + plugins: [dts({ emitDtsOnly: true })], + }, ]); diff --git a/src/vite.ts b/src/vite.ts new file mode 100644 index 0000000..21d9393 --- /dev/null +++ b/src/vite.ts @@ -0,0 +1,89 @@ +import path from 'node:path'; +import type { Plugin } from 'vite'; + +const PACKAGE = '@solidjs/start-devtools'; +const MOUNT_ID = 'virtual:solid-start-devtools/mount'; + +export interface DevtoolsPluginOptions { + /** + * Mount the toolbar in pages served from an `index.html`, so the app needs + * no `` in its JSX. Apps whose entry already renders the + * toolbar keep one, since the toolbar never mounts twice. + * + * @default true + */ + mount?: boolean; +} + +/** `base` and `path` joined with one slash between them. */ +function joinBase(base: string, file: string): string { + return `${base.replace(/\/+$/, '')}/${file.replace(/^\/+/, '')}`; +} + +/** + * The Vite plugin of the devtools. In development it mounts the toolbar into + * the page, so no JSX is needed. Builds, `vite preview` and Vitest are left + * alone, so production never carries the toolbar. + * + * ```ts + * import solid from '@solidjs/vite-plugin'; + * import devtools from '@solidjs/start-devtools/vite'; + * + * export default defineConfig({ plugins: [solid(), devtools()] }); + * ``` + * + * Start mode of `@solidjs/vite-plugin` mounts the toolbar through its own + * generated entries and serves no `index.html`, so this plugin adds nothing + * there. + */ +export default function devtools(options: DevtoolsPluginOptions = {}): Plugin { + const mount = options.mount ?? true; + let root = process.cwd(); + let base = '/'; + + return { + name: 'solid-start-devtools', + apply(_config, env) { + return env.command === 'serve' && !env.isPreview && env.mode !== 'test'; + }, + + // The mount module reaches the page behind the dependency scanner's back, + // through a script added to index.html. Without this, the first page load + // finds the package, optimizes it again and reloads the page. + config() { + if (!mount) return; + return { optimizeDeps: { include: [PACKAGE] } }; + }, + + configResolved(config) { + root = config.root; + base = config.base; + }, + + async resolveId(source, importer) { + if (source === MOUNT_ID) return { id: MOUNT_ID, moduleSideEffects: true }; + // The virtual module has no directory to resolve a package from, so the + // app's copy is found from the root, the one the app itself imports. + if (importer === MOUNT_ID && source === PACKAGE) { + return this.resolve(source, path.join(root, 'index.html'), { skipSelf: true }); + } + return null; + }, + + load(id) { + if (id !== MOUNT_ID) return null; + return [`import { mountDevToolbar } from '${PACKAGE}';`, 'mountDevToolbar();'].join('\n'); + }, + + transformIndexHtml() { + if (!mount) return undefined; + return [ + { + tag: 'script', + attrs: { type: 'module', src: joinBase(base, `/@id/${MOUNT_ID}`) }, + injectTo: 'body' as const, + }, + ]; + }, + }; +} diff --git a/tests/e2e/vite-plugin.spec.ts b/tests/e2e/vite-plugin.spec.ts new file mode 100644 index 0000000..5241e16 --- /dev/null +++ b/tests/e2e/vite-plugin.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const fixture = fileURLToPath(new URL('../fixture-vite', import.meta.url)); +const app = 'http://127.0.0.1:4174/'; + +test('mounts the toolbar into an app that does not render it', async ({ page }) => { + await page.goto(app); + await expect(page.locator('#increment')).toHaveText('count 0'); + await expect(page.locator('[data-solid-dev-toolbar]')).toHaveCount(1); + // It is the plugin's mount: its own host on the body, beside the app. + await expect(page.locator('body > [data-solid-dev-toolbar-root]')).toHaveCount(1); + await expect(page.locator('#app [data-solid-dev-toolbar]')).toHaveCount(0); + + // The app keeps working beside it. + await page.locator('#increment').click(); + await expect(page.locator('#increment')).toHaveText('count 1'); +}); + +test('leaves production builds without the toolbar', async () => { + const outDir = await mkdtemp(path.join(tmpdir(), 'start-devtools-vite-')); + try { + await build({ + configFile: path.join(fixture, 'vite.config.ts'), + logLevel: 'silent', + build: { outDir, emptyOutDir: true }, + }); + const html = await readFile(path.join(outDir, 'index.html'), 'utf8'); + expect(html).not.toContain('solid-start-devtools/mount'); + const assets = await readdir(path.join(outDir, 'assets')); + const scripts = await Promise.all( + assets + .filter((file) => file.endsWith('.js')) + .map((file) => readFile(path.join(outDir, 'assets', file), 'utf8')), + ); + expect(scripts.join('\n')).not.toContain('data-solid-dev-toolbar'); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); diff --git a/tests/fixture-vite/app.tsx b/tests/fixture-vite/app.tsx new file mode 100644 index 0000000..c874632 --- /dev/null +++ b/tests/fixture-vite/app.tsx @@ -0,0 +1,14 @@ +import { render } from '@solidjs/web'; +import { createSignal } from 'solid-js'; + +// No toolbar in the JSX: the Vite plugin mounts it. +function App() { + const [count, setCount] = createSignal(0, { name: 'count' }); + return ( + + ); +} + +render(() => , document.getElementById('app')!); diff --git a/tests/fixture-vite/index.html b/tests/fixture-vite/index.html new file mode 100644 index 0000000..d90ae17 --- /dev/null +++ b/tests/fixture-vite/index.html @@ -0,0 +1,12 @@ + + + + + + Start Devtools Vite Plugin Test + + +
+ + + diff --git a/tests/fixture-vite/vite.config.ts b/tests/fixture-vite/vite.config.ts new file mode 100644 index 0000000..6b1bd0f --- /dev/null +++ b/tests/fixture-vite/vite.config.ts @@ -0,0 +1,52 @@ +import { transformAsync } from '@solidjs/compiler'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, type Plugin } from 'vite'; +import devtools from '@solidjs/start-devtools/vite'; + +const builtIns = [ + 'For', + 'Show', + 'Switch', + 'Match', + 'Loading', + 'Reveal', + 'Portal', + 'Repeat', + 'Dynamic', + 'Errored', +]; + +function solid(): Plugin { + return { + name: 'solid-test', + enforce: 'pre', + async transform(code, id) { + const filename = id.replace(/\?.*$/, ''); + if (/[/\\]node_modules[/\\]/.test(filename) || !/\.[mc]?[jt]sx$/i.test(filename)) { + return null; + } + + const result = await transformAsync(code, { + filename, + moduleName: '@solidjs/web', + generate: 'dom', + sourceMap: true, + contextToCustomElements: true, + wrapConditionals: true, + builtIns, + }); + + return { code: result.code, map: result.map }; + }, + }; +} + +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [solid(), devtools()], + optimizeDeps: { + rolldownOptions: { + transform: { jsx: { runtime: 'classic' } }, + }, + }, +});