From 30e072d8415a68590cef09e51cb5c3c7a82c76c6 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 5 Nov 2026 01:20:50 +0800 Subject: [PATCH 1/2] feat: connect the toolbar to the dev server Add connectDevServer and the @solidjs/start-devtools/protocol entry, so @solidjs/vite-plugin can send what only the dev server knows: - errors the server runtime reports go to the Health view - open in editor uses the plugin's endpoint, one request resolved against the Vite root - the solid-js version falls back to the one the dev server resolved Co-Authored-By: Claude Opus 5.5 --- .changeset/dev-server-channel.md | 9 +++ package.json | 4 + rolldown.config.js | 9 ++- src/dev-toolbar/dev-server.test.ts | 117 +++++++++++++++++++++++++++++ src/dev-toolbar/dev-server.ts | 110 +++++++++++++++++++++++++++ src/dev-toolbar/error-record.ts | 12 +++ src/dev-toolbar/health/index.tsx | 7 +- src/dev-toolbar/index.tsx | 22 +++++- src/dev-toolbar/open-in-editor.ts | 19 ++++- src/index.tsx | 2 + src/noop.ts | 4 + src/protocol.ts | 84 +++++++++++++++++++++ src/server.tsx | 4 + tests/e2e/devtools.spec.ts | 29 +++++++ tests/fixture/app.tsx | 13 +++- tests/fixture/vite.config.ts | 44 ++++++++++- 16 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 .changeset/dev-server-channel.md create mode 100644 src/dev-toolbar/dev-server.test.ts create mode 100644 src/dev-toolbar/dev-server.ts create mode 100644 src/protocol.ts diff --git a/.changeset/dev-server-channel.md b/.changeset/dev-server-channel.md new file mode 100644 index 0000000..7ab35ef --- /dev/null +++ b/.changeset/dev-server-channel.md @@ -0,0 +1,9 @@ +--- +'@solidjs/start-devtools': minor +--- + +Add `connectDevServer`, which lets `@solidjs/vite-plugin` send the toolbar what only the dev server knows, and `@solidjs/start-devtools/protocol`, the messages and endpoints both sides use. + +- Errors the server runtime reports during a render, a server function or a request show in the Health view. +- Open in editor sends one request to the plugin's endpoint, which resolves the path against the Vite root. +- The solid-js version in the header comes from the dev server when the page cannot import the package file. diff --git a/package.json b/package.json index 540f304..ad54558 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "import": "./dist/index.js" }, "default": "./dist/noop.js" + }, + "./protocol": { + "types": "./dist/types/protocol.d.ts", + "default": "./dist/protocol.js" } }, "scripts": { diff --git a/rolldown.config.js b/rolldown.config.js index 9f834af..df68a36 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -115,8 +115,15 @@ export default defineConfig([ generate: 'ssr', server: true, }), + config({ + input: 'src/protocol.ts', + entryFileNames: 'protocol.js', + chunkFileNames: 'protocol-chunks/[name]-[hash].js', + generate: 'ssr', + server: true, + }), { - input: 'src/index.tsx', + input: ['src/index.tsx', 'src/protocol.ts'], output: { format: 'esm', dir: 'dist/types', diff --git a/src/dev-toolbar/dev-server.test.ts b/src/dev-toolbar/dev-server.test.ts new file mode 100644 index 0000000..621f57b --- /dev/null +++ b/src/dev-toolbar/dev-server.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { + DEVTOOLS_HELLO_EVENT, + DEVTOOLS_INFO_EVENT, + DEVTOOLS_SERVER_ERROR_EVENT, + type DevServerChannel, + type DevServerInfo, + type ServerErrorMessage, +} from '../protocol.js'; +import { createDevServerConnection, serverErrorPlace, serverErrorValue } from './dev-server.js'; + +function fakeChannel() { + const handlers = new Map void>(); + const sent: { event: string; data: unknown }[] = []; + const channel: DevServerChannel = { + on(event, listener) { + handlers.set(event, listener); + }, + send(event, data) { + sent.push({ event, data }); + }, + }; + function emit(event: string, data: unknown): void { + handlers.get(event)?.(data); + } + return { channel, sent, emit }; +} + +function serverError(over: Partial = {}): ServerErrorMessage { + return { + id: 1, + at: 1000, + name: 'Error', + message: 'boom', + stack: 'Error: boom\n at load (/app/src/api.ts:3:9)', + kind: 'render', + handling: 'failed', + ...over, + }; +} + +function info(errors: ServerErrorMessage[] = []): DevServerInfo { + return { base: '/', versions: { 'solid-js': '2.0.0' }, errors }; +} + +describe('createDevServerConnection', () => { + it('says hello with the time the page started loading', () => { + const connection = createDevServerConnection(); + const { channel, sent } = fakeChannel(); + connection.connect(channel, 500); + connection.connect(channel, 600); + + expect(sent).toEqual([{ event: DEVTOOLS_HELLO_EVENT, data: { since: 500 } }]); + }); + + it('hands a late listener the info and the errors that came before it', () => { + const connection = createDevServerConnection(); + const { channel, emit } = fakeChannel(); + connection.connect(channel, 0); + emit(DEVTOOLS_INFO_EVENT, info([serverError()])); + emit(DEVTOOLS_SERVER_ERROR_EVENT, serverError({ id: 2 })); + + const infos: DevServerInfo[] = []; + const ids: number[] = []; + connection.listen({ + onInfo: (value) => infos.push(value), + onServerError: (message) => ids.push(message.id), + }); + + expect(infos).toHaveLength(1); + expect(connection.info()?.versions['solid-js']).toBe('2.0.0'); + expect(ids).toEqual([1, 2]); + }); + + it('drops an error it already has', () => { + const connection = createDevServerConnection(); + const { channel, emit } = fakeChannel(); + connection.connect(channel, 0); + const ids: number[] = []; + connection.listen({ onServerError: (message) => ids.push(message.id) }); + + emit(DEVTOOLS_SERVER_ERROR_EVENT, serverError()); + emit(DEVTOOLS_INFO_EVENT, info([serverError()])); + + expect(ids).toEqual([1]); + }); + + it('stops calling a listener that was removed', () => { + const connection = createDevServerConnection(); + const { channel, emit } = fakeChannel(); + connection.connect(channel, 0); + const ids: number[] = []; + const stop = connection.listen({ onServerError: (message) => ids.push(message.id) }); + stop(); + emit(DEVTOOLS_SERVER_ERROR_EVENT, serverError()); + + expect(ids).toEqual([]); + }); +}); + +describe('serverErrorValue', () => { + it('keeps the name, message and stack the server sent', () => { + const error = serverErrorValue(serverError({ name: 'TypeError' })); + expect(error.name).toBe('TypeError'); + expect(error.message).toBe('boom'); + expect(error.stack).toContain('/app/src/api.ts:3:9'); + }); +}); + +describe('serverErrorPlace', () => { + it('names the server function that threw', () => { + expect(serverErrorPlace(serverError({ kind: 'server-function', functionId: 'abc' }))).toBe( + 'Server function abc', + ); + expect(serverErrorPlace(serverError({ kind: 'request' }))).toBe('Server request'); + }); +}); diff --git a/src/dev-toolbar/dev-server.ts b/src/dev-toolbar/dev-server.ts new file mode 100644 index 0000000..8cf434f --- /dev/null +++ b/src/dev-toolbar/dev-server.ts @@ -0,0 +1,110 @@ +import { + DEVTOOLS_HELLO_EVENT, + DEVTOOLS_INFO_EVENT, + DEVTOOLS_SERVER_ERROR_EVENT, + type DevServerChannel, + type DevServerHello, + type DevServerInfo, + type ServerErrorMessage, +} from '../protocol.js'; +import { MAX_ERRORS } from './error-list.js'; + +export interface DevServerListener { + onInfo?(info: DevServerInfo): void; + onServerError?(message: ServerErrorMessage): void; +} + +export interface DevServerConnection { + /** Sends the hello and starts listening to the channel. Later calls do nothing. */ + connect(channel: DevServerChannel, since: number): void; + /** The info the server answered with, once it has. */ + info(): DevServerInfo | undefined; + /** + * Adds a listener. It hears the info and the errors that arrived before it + * was added, then the ones after. The returned function removes it. + */ + listen(listener: DevServerListener): () => void; +} + +/** + * The toolbar's side of the dev server channel. + * + * The plugin connects before the toolbar renders, so the info and the first + * errors can arrive before any panel listens. The connection keeps them and + * hands them to each listener that joins later. + */ +export function createDevServerConnection(): DevServerConnection { + const listeners = new Set(); + const seen = new Set(); + const errors: ServerErrorMessage[] = []; + let info: DevServerInfo | undefined; + let connected = false; + + function receiveError(message: ServerErrorMessage): void { + // The info's errors and a live event can carry the same error. + if (seen.has(message.id)) return; + seen.add(message.id); + errors.push(message); + if (errors.length > MAX_ERRORS) seen.delete(errors.shift()!.id); + for (const listener of listeners) listener.onServerError?.(message); + } + + return { + connect(channel, since) { + if (connected) return; + connected = true; + channel.on(DEVTOOLS_INFO_EVENT, (data: DevServerInfo) => { + info = data; + for (const listener of listeners) listener.onInfo?.(data); + for (const message of data.errors) receiveError(message); + }); + channel.on(DEVTOOLS_SERVER_ERROR_EVENT, receiveError); + const hello: DevServerHello = { since }; + channel.send(DEVTOOLS_HELLO_EVENT, hello); + }, + info: () => info, + listen(listener) { + listeners.add(listener); + if (info) listener.onInfo?.(info); + for (const message of errors) listener.onServerError?.(message); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +const shared = createDevServerConnection(); + +/** + * Connects the toolbar to the dev server. `@solidjs/vite-plugin` calls this + * with `import.meta.hot` from the client entry it generates. + */ +export function connectDevServer(channel: DevServerChannel): void { + shared.connect(channel, performance.timeOrigin); +} + +export function listenToDevServer(listener: DevServerListener): () => void { + return shared.listen(listener); +} + +export function devServerInfo(): DevServerInfo | undefined { + return shared.info(); +} + +/** A server error as an `Error`, so the error panel shows it like one thrown in the page. */ +export function serverErrorValue(message: ServerErrorMessage): Error { + const error = new Error(message.message); + error.name = message.name; + error.stack = message.stack ?? `${message.name}: ${message.message}`; + return error; +} + +/** Where on the server the error happened, for the subtitle of its row. */ +export function serverErrorPlace(message: ServerErrorMessage): string { + if (message.kind === 'server-function') { + return message.functionId ? `Server function ${message.functionId}` : 'Server function'; + } + if (message.kind === 'render') return 'Server render'; + return 'Server request'; +} diff --git a/src/dev-toolbar/error-record.ts b/src/dev-toolbar/error-record.ts index 99b03d7..12d553d 100644 --- a/src/dev-toolbar/error-record.ts +++ b/src/dev-toolbar/error-record.ts @@ -1,5 +1,7 @@ import { OBSERVE } from 'solid-js'; import type { ChangeOrigin } from 'solid-js/attribution'; +import type { ServerErrorMessage } from '../protocol.js'; +import { serverErrorValue } from './dev-server.js'; /** A thrown value the toolbar caught, with when and during what. */ export interface ErrorRecord { @@ -11,6 +13,8 @@ export interface ErrorRecord { * object a timeline entry keeps as its `origin`, so the two join by identity. */ origin: ChangeOrigin | undefined; + /** Set when the dev server sent the error, which the page never threw. */ + server?: ServerErrorMessage; } export function createErrorRecord(error: unknown): ErrorRecord { @@ -23,3 +27,11 @@ export function createErrorRecord(error: unknown): ErrorRecord { } return { error, at: performance.now(), origin }; } + +/** The record of an error the dev server sent. */ +export function createServerErrorRecord(message: ServerErrorMessage): ErrorRecord { + // The server stamps the epoch clock. Move it to the page's clock, so a server + // error sorts and ages like the errors the page caught. + const at = message.at - performance.timeOrigin; + return { error: serverErrorValue(message), at, origin: undefined, server: message }; +} diff --git a/src/dev-toolbar/health/index.tsx b/src/dev-toolbar/health/index.tsx index a072551..52b2051 100644 --- a/src/dev-toolbar/health/index.tsx +++ b/src/dev-toolbar/health/index.tsx @@ -6,6 +6,7 @@ import { useResizableDetail } from '../../ui/use-resizable-detail.js'; import type { ErrorRecord } from '../error-record.js'; import { IssueDetail, matchesIssue, TONES } from '../issues/index.js'; import { isIssuesAvailable, issueText, type IssueEntry } from '../issues/tracker.js'; +import { serverErrorPlace } from '../dev-server.js'; import { describeError } from './describe-error.js'; import { HealthRow } from './HealthRow.js'; import { leadingPane } from './leading-pane.js'; @@ -214,7 +215,11 @@ export default function HealthViewer(props: HealthViewerProps): JSX.Element { data-solid-health-error-row tone="error" title={info().name} - subtitle={info().message} + subtitle={ + item.record.server + ? `${serverErrorPlace(item.record.server)}: ${info().message}` + : info().message + } time={errorAge(item.record)} selected={isSelected()} onClick={() => setSelected({ kind: 'error', index: item.index })} diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 59e3bc8..29e667b 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -27,7 +27,8 @@ import { isExtensionError } from './extension-errors.js'; import { addError } from './error-list.js'; import { CopyReportButton } from './report/CopyReportButton.js'; import { pageRoute } from './report/markdown.js'; -import { createErrorRecord, type ErrorRecord } from './error-record.js'; +import { createErrorRecord, createServerErrorRecord, type ErrorRecord } from './error-record.js'; +import { listenToDevServer } from './dev-server.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; import { startTimeline, TimelineBuilder, type TimelineEntry } from './timeline/tracker.js'; @@ -277,7 +278,10 @@ export function DevToolbar(props: DevToolbarProps) { if (saved.structureMode === 'owners' || saved.structureMode === 'graph') { setStructureMode(saved.structureMode); } - void loadSolidVersion().then((value) => setSolidVersion(value)); + void loadSolidVersion().then((value) => { + // The dev server's answer can come first, and it reads the same package. + if (value) setSolidVersion(value); + }); if (typeof saved.height === 'number') setHeight(clampHeight(saved.height)); if (typeof saved.highlightUpdates === 'boolean') setHighlightUpdates(saved.highlightUpdates); if (saved.open) setOpen(true); @@ -457,9 +461,23 @@ export function DevToolbar(props: DevToolbarProps) { window.addEventListener('error', onErrorEvent); window.addEventListener('unhandledrejection', onRejectionEvent); + // Errors the server runtime reported, when @solidjs/vite-plugin connected the toolbar. + const stopDevServer = listenToDevServer({ + onInfo(info) { + const fromServer = info.versions['solid-js']; + if (fromServer && !solidVersion()) setSolidVersion(fromServer); + }, + onServerError(message) { + // The dev server already printed it to its terminal. + setErrors((current) => addError(current, createServerErrorRecord(message))); + show('health'); + }, + }); + return () => { window.removeEventListener('error', onErrorEvent); window.removeEventListener('unhandledrejection', onRejectionEvent); + stopDevServer(); }; }); diff --git a/src/dev-toolbar/open-in-editor.ts b/src/dev-toolbar/open-in-editor.ts index 807ed5b..4fff3ea 100644 --- a/src/dev-toolbar/open-in-editor.ts +++ b/src/dev-toolbar/open-in-editor.ts @@ -1,9 +1,24 @@ +import { OPEN_IN_EDITOR_PATH } from '../protocol.js'; +import { devServerInfo } from './dev-server.js'; + /** * Asks the dev server to open the file in the user's IDE. - * Vite serves the `/__open-in-editor` endpoint in development. + * + * When `@solidjs/vite-plugin` connected the toolbar, its endpoint resolves the + * path against the Vite root, so one request is enough. */ export function openInEditor(location: string): void { - // Vite resolves relative paths against its server root (server.config.root). + const info = devServerInfo(); + if (info) { + const base = info.base.endsWith('/') ? info.base.slice(0, -1) : info.base; + void fetch(`${base}${OPEN_IN_EDITOR_PATH}?file=${encodeURIComponent(location)}`).catch( + () => {}, + ); + return; + } + + // Without the plugin, Vite's own `/__open-in-editor` endpoint resolves + // relative paths against its server root, which the toolbar does not know. // In monorepos or subproject setups (like examples/explorer), `location` may // include parent path segments (e.g. 'examples/explorer/src/App.tsx') while // Vite's root is already set to 'examples/explorer'. We dispatch the full location diff --git a/src/index.tsx b/src/index.tsx index 889b5b8..ad5c21f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,8 @@ import { } from './dev-toolbar/functions/tracker.js'; import { DevToolbar, type DevToolbarProps } from './dev-toolbar/index.js'; +export { connectDevServer } from './dev-toolbar/dev-server.js'; + let dispose: (() => void) | undefined; let frame: number | undefined; diff --git a/src/noop.ts b/src/noop.ts index b5bfad0..0e1d7be 100644 --- a/src/noop.ts +++ b/src/noop.ts @@ -1,3 +1,4 @@ +import type { DevServerChannel } from './protocol.js'; import type { ServerFunctionCall } from './dev-toolbar/functions/tracker.js'; import type { DevToolbarProps } from './dev-toolbar/index.js'; @@ -7,6 +8,9 @@ export function DevToolbar(props: DevToolbarProps) { return props.children; } +/** The dev server channel reaches the toolbar in the browser only. */ +export function connectDevServer(_channel: DevServerChannel): void {} + export function mountDevToolbar(): () => void { return () => {}; } diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..0c87df6 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,84 @@ +/** + * What the dev server and the toolbar send each other. + * + * `@solidjs/vite-plugin` serves the dev server side. It imports these types + * and declares each constant with the type of the one here, so a change on + * either side fails the other's compile instead of dropping messages. + */ + +/** The path every devtools endpoint of the dev server starts with, after the Vite base. */ +export const DEVTOOLS_ENDPOINT = '/__solid/devtools'; + +/** + * Opens a file in the editor: `GET /__solid/devtools/open-in-editor?file=src/App.tsx:12:5`. + * The dev server resolves the path against its root, so the toolbar does not + * need to know where the root is. + */ +export const OPEN_IN_EDITOR_PATH = '/__solid/devtools/open-in-editor'; + +/** Toolbar to server, once it connects. The payload is a `DevServerHello`. */ +export const DEVTOOLS_HELLO_EVENT = 'solid:devtools:hello'; + +/** Server to toolbar, the answer to the hello. The payload is a `DevServerInfo`. */ +export const DEVTOOLS_INFO_EVENT = 'solid:devtools:info'; + +/** Server to toolbar, each server error after the hello. The payload is a `ServerErrorMessage`. */ +export const DEVTOOLS_SERVER_ERROR_EVENT = 'solid:devtools:server-error'; + +export interface DevServerHello { + /** + * When the page started loading, in milliseconds since the epoch + * (`performance.timeOrigin`). The server answers with the errors it kept + * from then on, so the errors of the render that served this page are + * included and those of earlier pages are not. + */ + since: number; +} + +/** Package versions the dev server resolved from the app root. A package it could not find is left out. */ +export interface DevServerVersions { + 'solid-js'?: string; + '@solidjs/web'?: string; + '@solidjs/vite-plugin'?: string; + vite?: string; +} + +export interface DevServerInfo { + /** The Vite `base`, which the endpoint paths go after. */ + base: string; + versions: DevServerVersions; + /** The server errors kept since the hello's `since`, oldest first. */ + errors: ServerErrorMessage[]; +} + +/** A failure the server runtime reported, as `configureServerErrors` hears it. */ +export interface ServerErrorMessage { + /** Unique for the life of the dev server, so a toolbar can drop one it already has. */ + id: number; + /** When the server heard it, in milliseconds since the epoch. */ + at: number; + name: string; + message: string; + /** Mapped to the source files by the dev server's module runner. */ + stack?: string; + kind: 'render' | 'server-function' | 'request'; + /** How the runtime handled it, such as `fallback` or `failed`. */ + handling: string; + /** The server function that threw, for `kind: 'server-function'`. */ + functionId?: string; + /** The component labels from the root to where the error was thrown. */ + ownerPath?: string[]; + /** The method and URL of the request the failure happened in. */ + method?: string; + url?: string; +} + +/** + * The part of Vite's `import.meta.hot` the toolbar uses. The plugin passes + * `import.meta.hot` from a module Vite serves, since a pre-bundled package + * has none of its own. + */ +export interface DevServerChannel { + on(event: string, listener: (data: any) => void): void; + send(event: string, data?: unknown): void; +} diff --git a/src/server.tsx b/src/server.tsx index cf72986..46d9821 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -1,3 +1,4 @@ +import type { DevServerChannel } from './protocol.js'; import { pushServerFunctionCall, type ServerFunctionCall, @@ -6,6 +7,9 @@ import { DevToolbar, type DevToolbarProps } from './dev-toolbar/index.js'; export { DevToolbar, type DevToolbarProps, pushServerFunctionCall, type ServerFunctionCall }; +/** The dev server channel reaches the toolbar in the browser only. */ +export function connectDevServer(_channel: DevServerChannel): void {} + export function mountDevToolbar(): () => void { return () => {}; } diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 0e623a0..279f628 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -1057,3 +1057,32 @@ test('removes the toolbar from production bundles', async () => { await rm(outDir, { recursive: true, force: true }); } }); + +test('shows the errors the dev server sent', async ({ page }) => { + await page.goto('/?dev-server'); + + await expect(page.getByRole('tab', { name: 'Health', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.locator('[data-solid-health-error-row]')).toContainText( + 'Server render: rendered on the server', + ); + await expect(page.locator('[data-solid-error-viewer-error-info-message]')).toHaveText( + 'rendered on the server', + ); +}); + +test('opens files through the dev server endpoint', async ({ page }) => { + await page.goto('/?dev-server'); + await page.locator('#throw-client-error').click(); + await page.locator('[data-solid-health-error-row]', { hasText: 'client boom' }).click(); + + const open = page.locator('button[title^="Open "][title$=" in editor"]').first(); + await open.click(); + + // One request with the location as the frame gave it, not one per sub-path. + await expect + .poll(async () => (await page.request.get('/__test/opened')).json()) + .toEqual([expect.stringContaining('app.tsx')]); +}); diff --git a/tests/fixture/app.tsx b/tests/fixture/app.tsx index 16507c9..aee68f7 100644 --- a/tests/fixture/app.tsx +++ b/tests/fixture/app.tsx @@ -1,5 +1,11 @@ +/// import { Loading, render } from '@solidjs/web'; -import { DevToolbar, mountDevToolbar, pushServerFunctionCall } from '@solidjs/start-devtools'; +import { + connectDevServer, + DevToolbar, + mountDevToolbar, + pushServerFunctionCall, +} from '@solidjs/start-devtools'; import { serializeString } from '@solidjs/web/server-functions/client'; import { createEffect, createMemo, createSignal, OBSERVE, Show } from 'solid-js'; @@ -189,6 +195,11 @@ function App() { ); } +// Stands in for @solidjs/vite-plugin, which connects from the client entry it generates. +if (new URLSearchParams(location.search).has('dev-server') && import.meta.hot) { + connectDevServer(import.meta.hot); +} + if (new URLSearchParams(location.search).has('mount')) { const first = mountDevToolbar(); const second = mountDevToolbar(); diff --git a/tests/fixture/vite.config.ts b/tests/fixture/vite.config.ts index e2e6a8e..770a925 100644 --- a/tests/fixture/vite.config.ts +++ b/tests/fixture/vite.config.ts @@ -40,9 +40,51 @@ function solid(): Plugin { }; } +/** + * The dev server side of the devtools protocol, as @solidjs/vite-plugin serves + * it: one server error kept from before the page loaded, and an open-in-editor + * endpoint that records the files it was asked to open. + */ +function fakeDevServer(): Plugin { + const opened: string[] = []; + return { + name: 'fake-devtools-dev-server', + apply: 'serve', + configureServer(server) { + server.ws.on('solid:devtools:hello', (hello: { since: number }, client) => { + client.send('solid:devtools:info', { + base: '/', + versions: { 'solid-js': 'from-dev-server' }, + errors: [ + { + id: 1, + at: hello.since + 1, + name: 'Error', + message: 'rendered on the server', + stack: 'Error: rendered on the server\n at load (/app/src/api.ts:3:9)', + kind: 'render', + handling: 'failed', + method: 'GET', + url: 'http://localhost/', + }, + ], + }); + }); + server.middlewares.use('/__solid/devtools/open-in-editor', (req, res) => { + opened.push(new URL(req.url!, 'http://localhost').searchParams.get('file') ?? ''); + res.end(); + }); + server.middlewares.use('/__test/opened', (_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(opened)); + }); + }, + }; +} + export default defineConfig({ root: fileURLToPath(new URL('.', import.meta.url)), - plugins: [solid()], + plugins: [solid(), fakeDevServer()], optimizeDeps: { rolldownOptions: { transform: { jsx: { runtime: 'classic' } }, From 342a63b83b1016659f278bb8333c03a7f3b6cc0b Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 5 Nov 2026 01:27:17 +0800 Subject: [PATCH 2/2] fix: show a server render error once, and drop the base from the protocol An that renders its fallback on the server sends the error with the page, so hydration throws it again. The page's record and the server's now merge into one. The plugin's endpoint is mounted ahead of Vite's base handling, like Vite's own /__open-in-editor, so the info no longer carries the base. Co-Authored-By: Claude Opus 5.5 --- src/dev-toolbar/dev-server.test.ts | 2 +- src/dev-toolbar/dev-server.ts | 8 ++++ src/dev-toolbar/error-record.test.ts | 64 ++++++++++++++++++++++++++++ src/dev-toolbar/error-record.ts | 43 ++++++++++++++++++- src/dev-toolbar/index.tsx | 13 ++++-- src/dev-toolbar/open-in-editor.ts | 8 +--- src/protocol.ts | 10 ++--- tests/fixture/vite.config.ts | 1 - 8 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 src/dev-toolbar/error-record.test.ts diff --git a/src/dev-toolbar/dev-server.test.ts b/src/dev-toolbar/dev-server.test.ts index 621f57b..b24f09c 100644 --- a/src/dev-toolbar/dev-server.test.ts +++ b/src/dev-toolbar/dev-server.test.ts @@ -40,7 +40,7 @@ function serverError(over: Partial = {}): ServerErrorMessage } function info(errors: ServerErrorMessage[] = []): DevServerInfo { - return { base: '/', versions: { 'solid-js': '2.0.0' }, errors }; + return { versions: { 'solid-js': '2.0.0' }, errors }; } describe('createDevServerConnection', () => { diff --git a/src/dev-toolbar/dev-server.ts b/src/dev-toolbar/dev-server.ts index 8cf434f..45af2d6 100644 --- a/src/dev-toolbar/dev-server.ts +++ b/src/dev-toolbar/dev-server.ts @@ -92,14 +92,22 @@ export function devServerInfo(): DevServerInfo | undefined { return shared.info(); } +const serverErrorValues = new WeakSet(); + /** A server error as an `Error`, so the error panel shows it like one thrown in the page. */ export function serverErrorValue(message: ServerErrorMessage): Error { const error = new Error(message.message); error.name = message.name; error.stack = message.stack ?? `${message.name}: ${message.message}`; + serverErrorValues.add(error); return error; } +/** Whether `serverErrorValue` made the value, so the page never threw it. */ +export function isServerErrorValue(value: unknown): boolean { + return typeof value === 'object' && value !== null && serverErrorValues.has(value); +} + /** Where on the server the error happened, for the subtitle of its row. */ export function serverErrorPlace(message: ServerErrorMessage): string { if (message.kind === 'server-function') { diff --git a/src/dev-toolbar/error-record.test.ts b/src/dev-toolbar/error-record.test.ts new file mode 100644 index 0000000..fb4bf59 --- /dev/null +++ b/src/dev-toolbar/error-record.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import type { ServerErrorMessage } from '../protocol.js'; +import { + addPageError, + addServerError, + createServerErrorRecord, + type ErrorRecord, +} from './error-record.js'; + +function serverMessage(over: Partial = {}): ServerErrorMessage { + return { + id: 1, + at: performance.timeOrigin, + name: 'Error', + message: 'boom', + kind: 'render', + handling: 'fallback', + ...over, + }; +} + +function pageRecord(message = 'boom'): ErrorRecord { + return { error: new Error(message), at: 10, origin: undefined }; +} + +describe('render errors the page throws again', () => { + it('merges the server copy into the error the page caught first', () => { + const page = pageRecord(); + const list = addServerError([page], createServerErrorRecord(serverMessage())); + + expect(list).toHaveLength(1); + expect(list[0]!.error).toBe(page.error); + expect(list[0]!.server?.handling).toBe('fallback'); + }); + + it('merges the page error into the server copy that came first', () => { + const page = pageRecord(); + const list = addPageError([createServerErrorRecord(serverMessage())], page); + + expect(list).toHaveLength(1); + expect(list[0]!.error).toBe(page.error); + expect(list[0]!.server?.id).toBe(1); + }); + + it('keeps errors with another message, or from a server function, apart', () => { + expect( + addServerError([pageRecord('other')], createServerErrorRecord(serverMessage())), + ).toHaveLength(2); + expect( + addPageError( + [createServerErrorRecord(serverMessage({ kind: 'server-function' }))], + pageRecord(), + ), + ).toHaveLength(2); + }); + + it('merges one page error into one server copy only', () => { + let list = addServerError([], createServerErrorRecord(serverMessage())); + list = addPageError(list, pageRecord()); + list = addPageError(list, pageRecord()); + + expect(list).toHaveLength(2); + }); +}); diff --git a/src/dev-toolbar/error-record.ts b/src/dev-toolbar/error-record.ts index 12d553d..4f26492 100644 --- a/src/dev-toolbar/error-record.ts +++ b/src/dev-toolbar/error-record.ts @@ -1,7 +1,9 @@ import { OBSERVE } from 'solid-js'; import type { ChangeOrigin } from 'solid-js/attribution'; import type { ServerErrorMessage } from '../protocol.js'; -import { serverErrorValue } from './dev-server.js'; +import { isServerErrorValue, serverErrorValue } from './dev-server.js'; +import { addError } from './error-list.js'; +import { describeError } from './health/describe-error.js'; /** A thrown value the toolbar caught, with when and during what. */ export interface ErrorRecord { @@ -35,3 +37,42 @@ export function createServerErrorRecord(message: ServerErrorMessage): ErrorRecor const at = message.at - performance.timeOrigin; return { error: serverErrorValue(message), at, origin: undefined, server: message }; } + +/** + * Whether one record is the page's copy of a server render error the other + * holds. An `` that renders its fallback on the server sends the + * error with the page, so hydration throws it again in the page. + */ +function isSameRenderError(server: ErrorRecord, page: ErrorRecord): boolean { + if (!server.server || server.server.kind !== 'render' || page.server) return false; + // A server record the page already merged into holds the page's error, and takes no other. + if (!isServerErrorValue(server.error)) return false; + const a = describeError(server.error); + const b = describeError(page.error); + return a.name === b.name && a.message === b.message; +} + +/** Adds an error the page caught, merged into the server's record of it when there is one. */ +export function addPageError(list: readonly ErrorRecord[], record: ErrorRecord): ErrorRecord[] { + for (let index = 0; index < list.length; index++) { + const current = list[index]!; + if (!isSameRenderError(current, record)) continue; + // The page's error keeps its place in the timeline, and the server's facts. + const next = [...list]; + next[index] = { ...record, server: current.server }; + return next; + } + return addError(list, record); +} + +/** Adds an error the dev server sent, merged into the page's copy of it when there is one. */ +export function addServerError(list: readonly ErrorRecord[], record: ErrorRecord): ErrorRecord[] { + for (let index = 0; index < list.length; index++) { + const current = list[index]!; + if (!isSameRenderError(record, current)) continue; + const next = [...list]; + next[index] = { ...current, server: record.server }; + return next; + } + return addError(list, record); +} diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 29e667b..beaf544 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -24,10 +24,15 @@ import { installElementTracking, type InspectedComponent } from './inspector/loo import { InspectorOverlay } from './inspector/overlay.js'; import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js'; import { isExtensionError } from './extension-errors.js'; -import { addError } from './error-list.js'; import { CopyReportButton } from './report/CopyReportButton.js'; import { pageRoute } from './report/markdown.js'; -import { createErrorRecord, createServerErrorRecord, type ErrorRecord } from './error-record.js'; +import { + addPageError, + addServerError, + createErrorRecord, + createServerErrorRecord, + type ErrorRecord, +} from './error-record.js'; import { listenToDevServer } from './dev-server.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; @@ -438,7 +443,7 @@ export function DevToolbar(props: DevToolbarProps) { function pushError(error: unknown) { console.error(error); const record = createErrorRecord(error); - setErrors((current) => addError(current, record)); + setErrors((current) => addPageError(current, record)); show('health'); } @@ -469,7 +474,7 @@ export function DevToolbar(props: DevToolbarProps) { }, onServerError(message) { // The dev server already printed it to its terminal. - setErrors((current) => addError(current, createServerErrorRecord(message))); + setErrors((current) => addServerError(current, createServerErrorRecord(message))); show('health'); }, }); diff --git a/src/dev-toolbar/open-in-editor.ts b/src/dev-toolbar/open-in-editor.ts index 4fff3ea..7f76db8 100644 --- a/src/dev-toolbar/open-in-editor.ts +++ b/src/dev-toolbar/open-in-editor.ts @@ -8,12 +8,8 @@ import { devServerInfo } from './dev-server.js'; * path against the Vite root, so one request is enough. */ export function openInEditor(location: string): void { - const info = devServerInfo(); - if (info) { - const base = info.base.endsWith('/') ? info.base.slice(0, -1) : info.base; - void fetch(`${base}${OPEN_IN_EDITOR_PATH}?file=${encodeURIComponent(location)}`).catch( - () => {}, - ); + if (devServerInfo()) { + void fetch(`${OPEN_IN_EDITOR_PATH}?file=${encodeURIComponent(location)}`).catch(() => {}); return; } diff --git a/src/protocol.ts b/src/protocol.ts index 0c87df6..abef200 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -6,13 +6,11 @@ * either side fails the other's compile instead of dropping messages. */ -/** The path every devtools endpoint of the dev server starts with, after the Vite base. */ -export const DEVTOOLS_ENDPOINT = '/__solid/devtools'; - /** - * Opens a file in the editor: `GET /__solid/devtools/open-in-editor?file=src/App.tsx:12:5`. + * Opens a file in the editor: `GET /__solid/devtools/open-in-editor?file=src/App.tsx:12:5`. * The dev server resolves the path against its root, so the toolbar does not - * need to know where the root is. + * need to know where the root is. Like Vite's own `/__open-in-editor`, the + * path does not take the Vite `base`. */ export const OPEN_IN_EDITOR_PATH = '/__solid/devtools/open-in-editor'; @@ -44,8 +42,6 @@ export interface DevServerVersions { } export interface DevServerInfo { - /** The Vite `base`, which the endpoint paths go after. */ - base: string; versions: DevServerVersions; /** The server errors kept since the hello's `since`, oldest first. */ errors: ServerErrorMessage[]; diff --git a/tests/fixture/vite.config.ts b/tests/fixture/vite.config.ts index 770a925..f82f037 100644 --- a/tests/fixture/vite.config.ts +++ b/tests/fixture/vite.config.ts @@ -53,7 +53,6 @@ function fakeDevServer(): Plugin { configureServer(server) { server.ws.on('solid:devtools:hello', (hello: { since: number }, client) => { client.send('solid:devtools:info', { - base: '/', versions: { 'solid-js': 'from-dev-server' }, errors: [ {