Skip to content
Open
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/vite-plugin.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"import": "./dist/index.js"
},
"default": "./dist/noop.js"
},
"./vite": {
"types": "./dist/types/vite.d.ts",
"default": "./dist/vite.js"
}
},
"scripts": {
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 14 additions & 5 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
});
22 changes: 22 additions & 0 deletions rolldown.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })],
},
]);
89 changes: 89 additions & 0 deletions src/vite.ts
Original file line number Diff line number Diff line change
@@ -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 `<DevToolbar>` 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,
},
];
},
};
}
44 changes: 44 additions & 0 deletions tests/e2e/vite-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
14 changes: 14 additions & 0 deletions tests/fixture-vite/app.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button id="increment" onClick={() => setCount((value) => value + 1)}>
{`count ${count()}`}
</button>
);
}

render(() => <App />, document.getElementById('app')!);
12 changes: 12 additions & 0 deletions tests/fixture-vite/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Start Devtools Vite Plugin Test</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/app.tsx"></script>
</body>
</html>
52 changes: 52 additions & 0 deletions tests/fixture-vite/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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' } },
},
},
});
Loading