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..b24f09c --- /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 { 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..45af2d6 --- /dev/null +++ b/src/dev-toolbar/dev-server.ts @@ -0,0 +1,118 @@ +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(); +} + +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') { + 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.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 99b03d7..4f26492 100644 --- a/src/dev-toolbar/error-record.ts +++ b/src/dev-toolbar/error-record.ts @@ -1,5 +1,9 @@ import { OBSERVE } from 'solid-js'; import type { ChangeOrigin } from 'solid-js/attribution'; +import type { ServerErrorMessage } from '../protocol.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 { @@ -11,6 +15,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 +29,50 @@ 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 }; +} + +/** + * 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/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..beaf544 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -24,10 +24,16 @@ 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, 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'; import { startTimeline, TimelineBuilder, type TimelineEntry } from './timeline/tracker.js'; @@ -277,7 +283,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); @@ -434,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'); } @@ -457,9 +466,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) => addServerError(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..7f76db8 100644 --- a/src/dev-toolbar/open-in-editor.ts +++ b/src/dev-toolbar/open-in-editor.ts @@ -1,9 +1,20 @@ +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). + if (devServerInfo()) { + void fetch(`${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..abef200 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * 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. 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'; + +/** 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 { + 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..f82f037 100644 --- a/tests/fixture/vite.config.ts +++ b/tests/fixture/vite.config.ts @@ -40,9 +40,50 @@ 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', { + 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' } },