From 33371394c06794c38f855e0c534ed347303f3748 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 20 Apr 2026 17:43:53 -0700 Subject: [PATCH] chore: enable dashboard hmr in watch mode --- packages/dashboard/src/dashboard.tsx | 2 +- packages/dashboard/src/dashboardContext.ts | 24 ++++++++ packages/dashboard/src/index.tsx | 12 ++-- packages/dashboard/src/sessionSidebar.tsx | 2 +- packages/dashboard/vite.config.ts | 2 +- .../src/tools/dashboard/dashboardApp.ts | 61 ++++++++++++++++--- packages/utils/httpServer.ts | 13 +++- utils/build/build.js | 14 ++++- 8 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 packages/dashboard/src/dashboardContext.ts diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index 41feefd7c3f05..f72c6484fbce2 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -16,7 +16,7 @@ import React from 'react'; import './dashboard.css'; -import { DashboardClientContext } from './index'; +import { DashboardClientContext } from './dashboardContext'; import { asLocator } from '@isomorphic/locatorGenerators'; import { ChevronLeftIcon, ChevronRightIcon, ReloadIcon } from './icons'; import { Annotations, getImageLayout, clientToViewport } from './annotations'; diff --git a/packages/dashboard/src/dashboardContext.ts b/packages/dashboard/src/dashboardContext.ts new file mode 100644 index 0000000000000..0c8c15c21162e --- /dev/null +++ b/packages/dashboard/src/dashboardContext.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// HMR: extracted from index.tsx so index.tsx only exports components and stays +// a clean Fast Refresh boundary (mixed exports break @vitejs/plugin-react HMR). + +import React from 'react'; + +import type { DashboardClientChannel } from './dashboardClient'; + +export const DashboardClientContext = React.createContext(undefined); diff --git a/packages/dashboard/src/index.tsx b/packages/dashboard/src/index.tsx index 8b55bd35c7f01..88fe0f69550d9 100644 --- a/packages/dashboard/src/index.tsx +++ b/packages/dashboard/src/index.tsx @@ -21,17 +21,14 @@ import '@web/common.css'; import './common.css'; import { applyTheme } from '@web/theme'; import { Dashboard } from './dashboard'; +import { DashboardClientContext } from './dashboardContext'; import { SessionModel } from './sessionModel'; import { DashboardClient } from './dashboardClient'; import { SessionSidebar } from './sessionSidebar'; import { SplitView } from '@web/components/splitView'; -import type { DashboardClientChannel } from './dashboardClient'; - applyTheme(); -export const DashboardClientContext = React.createContext(undefined); - const client = DashboardClient.create('/ws'); const model = new SessionModel(client); @@ -64,4 +61,9 @@ const App: React.FC = () => { ; }; -ReactDOM.createRoot(document.querySelector('#root')!).render(); +// HMR begin: cache the root on the DOM node so re-running this module during +// an HMR update reuses it instead of calling createRoot twice on the same container. +const rootElement = document.querySelector('#root')! as HTMLElement & { __dashboardRoot?: ReactDOM.Root }; +const root = rootElement.__dashboardRoot ??= ReactDOM.createRoot(rootElement); +root.render(); +// HMR end diff --git a/packages/dashboard/src/sessionSidebar.tsx b/packages/dashboard/src/sessionSidebar.tsx index d7290991045e9..d2771cd81eedf 100644 --- a/packages/dashboard/src/sessionSidebar.tsx +++ b/packages/dashboard/src/sessionSidebar.tsx @@ -16,7 +16,7 @@ import React from 'react'; import './sessionSidebar.css'; -import { DashboardClientContext } from './index'; +import { DashboardClientContext } from './dashboardContext'; import { SettingsButton } from './settingsView'; import { ToolbarButton } from '@web/components/toolbarButton'; diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index 967cf2ba81dae..d4f0b3ad9b1c8 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -40,5 +40,5 @@ export default defineConfig({ manualChunks: undefined, }, }, - } + }, }); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 84aafe5e38f3e..772e5fb0ccb04 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -32,6 +32,11 @@ import { DashboardConnection } from './dashboardController'; import type * as api from '../../..'; import type { AnnotationData } from '@dashboard/dashboardChannel'; +// HMR: build-time flag — `true` in watch builds, `false` in release. esbuild +// replaces the identifier via `define`, so the static branch pays zero runtime +// cost and the dev-server code (incl. `import('vite')`) is DCE'd in release. +declare const __PW_DASHBOARD_HMR__: boolean; + type DashboardServer = { url: string; reveal: (options: DashboardOptions) => void; @@ -74,14 +79,14 @@ async function startDashboardServer(options: DashboardOptions): Promise { - const pathname = new URL(request.url!, `http://${request.headers.host}`).pathname; - const filePath = pathname === '/' ? 'index.html' : pathname.substring(1); - const resolved = path.join(dashboardDir, filePath); - if (!resolved.startsWith(dashboardDir)) - return false; - return httpServer.serveFile(request, response, resolved); - }); + // HMR: watch builds serve the dashboard through an embedded Vite dev server + // so edits to packages/dashboard/src/* reload live. Release builds always + // take the static branch (the dev-server arm is DCE'd). Set + // PW_DASHBOARD_STATIC=1 during watch to exercise the bundled output. + if (__PW_DASHBOARD_HMR__ && process.env.PW_DASHBOARD_STATIC !== '1') + await attachDashboardDevServer(httpServer); + else + attachDashboardStaticServer(httpServer, dashboardDir); await httpServer.start({ port: options.port, host: options.host }); const reveal = (next: DashboardOptions) => { @@ -111,6 +116,46 @@ async function startDashboardServer(options: DashboardOptions): Promise { + const pathname = new URL(request.url!, `http://${request.headers.host}`).pathname; + const filePath = pathname === '/' ? 'index.html' : pathname.substring(1); + const resolved = path.join(dashboardDir, filePath); + if (!resolved.startsWith(dashboardDir)) + return false; + return httpServer.serveFile(request, response, resolved); + }); +} + +// HMR begin: dev-mode branch — wires a Vite dev server into HttpServer. +async function attachDashboardDevServer(httpServer: HttpServer) { + const dashboardRoot = path.resolve(__dirname, '..', '..', 'dashboard'); + const loadVite = new Function('return import("vite")') as () => Promise; + const vite = await loadVite(); + const devServer = await vite.createServer({ + root: dashboardRoot, + configFile: path.join(dashboardRoot, 'vite.config.ts'), + server: { + middlewareMode: true, + // HMR: dedicated path so this websocket does not collide with the + // dashboard IPC websocket HttpServer owns at /ws. + hmr: { path: '/__vite_hmr', server: httpServer.server() }, + }, + appType: 'spa', + clearScreen: false, + }); + httpServer.routePrefix('/', (request: http.IncomingMessage, response: http.ServerResponse) => { + devServer.middlewares(request, response, () => { + if (!response.headersSent) { + response.statusCode = 404; + response.end(); + } + }); + return true; + }); +} +// HMR end + async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> { const server = await startDashboardServer(options); const { page } = await launchApp('dashboard'); diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index 5229f89985e57..e3659002a3ab0 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -67,7 +67,18 @@ export class HttpServer { createWebSocket(transportFactory: (url: URL) => Transport, guid?: string) { assert(!this._wsGuid, 'can only create one main websocket transport per server'); this._wsGuid = guid || createGuid(); - const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid }); + // HMR begin: route upgrades manually with `noServer` so Vite HMR's upgrade + // listener on the same http.Server is not pre-empted. With `{ server, path }` + // the ws library aborts non-matching upgrades with 400. + const wsPath = '/' + this._wsGuid; + const wss = new wsServer({ noServer: true }); + this._server.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url ?? '/', 'http://localhost').pathname; + if (pathname !== wsPath) + return; + wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request)); + }); + // HMR end wss.on('connection', (ws, request) => { const url = new URL(request.url ?? '/', 'http://localhost'); const transport = transportFactory(url); diff --git a/utils/build/build.js b/utils/build/build.js index 05436a15934f5..fddff1ae59ef7 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -634,6 +634,12 @@ steps.push(new EsbuildStep({ 'chromium-bidi/*', 'mitt', ], + // HMR: baked-in flag that enables the dashboard Vite dev server in watch + // builds. In release builds it's `false` and esbuild dead-code-eliminates + // the whole dev-server branch (including the `import('vite')` call). + define: { + __PW_DASHBOARD_HMR__: String(!!watchMode), + }, plugins: [{ name: 'externalize-utilsBundle', setup: build => build.onResolve({ filter: /utilsBundle/ }, @@ -876,7 +882,13 @@ steps.push(new ProgramStep({ })); // Build/watch web packages. -for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer', 'dashboard']) { +// HMR: in watch mode the dashboard is served by the embedded Vite dev server +// in dashboardApp.ts, so skip its `vite build --watch` step. Set +// PW_DASHBOARD_STATIC=1 to keep the watch-build for testing the bundled output. +const hmrReplacesDashboardBuild = watchMode && process.env.PW_DASHBOARD_STATIC !== '1'; +const webPackages = ['html-reporter', 'recorder', 'trace-viewer', 'dashboard'] + .filter(pkg => !(pkg === 'dashboard' && hmrReplacesDashboardBuild)); +for (const webPackage of webPackages) { steps.push(new ProgramStep({ command: 'npx', args: [