diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index 56e7ec7e31..b58be3e5ba 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -160,6 +160,20 @@ if (!devUrl) { process.exit(1); } +// Let Vite finish the initial dependency crawl + optimizer commit before the +// window loads (issue #4775). `warmupRequest` on the renderer entry kicks the +// recursive pre-transform of the static import graph (which registers every +// reachable dep with the optimizer), and `waitForRequestsIdle` resolves at +// crawl end — the same signal the dep optimizer waits on before committing +// node_modules/.vite/deps. Loading Electron before that commit let the page +// execute chunks from a previous optimizer generation alongside fresh ones — +// two React instances, a null hook dispatcher, and a renderer crash on the +// first lazy component. warmupRequest swallows transform errors itself, so +// this can never abort the launch; it only reorders the startup race away. +log('vite', 'warming renderer entry and waiting for the dep crawl to settle...'); +await server.environments.client.warmupRequest('/main.tsx'); +await server.environments.client.waitForRequestsIdle(); + log('electron', `launching against ${devUrl} (renderer HMR live)`); // Created before launch so signals during codesign/preparation are durable. diff --git a/apps/desktop/src/main/__tests__/main-renderer-dev-cache.test.ts b/apps/desktop/src/main/__tests__/main-renderer-dev-cache.test.ts new file mode 100644 index 0000000000..ee97638993 --- /dev/null +++ b/apps/desktop/src/main/__tests__/main-renderer-dev-cache.test.ts @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { clearDevRendererHttpCache } from '../main-renderer-dev-cache.js'; + +describe('dev renderer HTTP cache hygiene (issue #4775)', () => { + it('clears the session HTTP cache before loading a Vite dev server', async () => { + let cleared = 0; + const session = { async clearCache() { cleared += 1; } }; + const didClear = await clearDevRendererHttpCache(session, { useDevServer: true }); + assert.equal(didClear, true); + assert.equal(cleared, 1); + }); + + it('leaves the cache alone for packaged file:// builds', async () => { + let cleared = 0; + const session = { async clearCache() { cleared += 1; } }; + const didClear = await clearDevRendererHttpCache(session, { useDevServer: false }); + assert.equal(didClear, false); + assert.equal(cleared, 0); + }); + + it('downgrades a clearCache failure to a warning instead of blocking load', async () => { + const session = { + async clearCache(): Promise { throw new Error('cache locked'); }, + }; + const didClear = await clearDevRendererHttpCache(session, { useDevServer: true }); + assert.equal(didClear, false); + }); +}); diff --git a/apps/desktop/src/main/main-renderer-dev-cache.ts b/apps/desktop/src/main/main-renderer-dev-cache.ts new file mode 100644 index 0000000000..adeb1071f5 --- /dev/null +++ b/apps/desktop/src/main/main-renderer-dev-cache.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +/** + * Dev-server cache hygiene (issue #4775). + * + * Vite serves optimized deps as `Cache-Control: immutable` keyed by per-dep + * `?v=` browserHash labels, and the optimizer preserves a dep's label across + * re-optimization commits — including ones where the dep's bundle bytes + * changed (Vite upgrade, patch re-application, interrupted commit). The + * persistent session cache then resurrects a previous generation's + * react/react-dom chunk graph next to freshly transformed sources that import + * today's react copy: two React instances in one page, a null hook + * dispatcher, and `Cannot read properties of null (reading 'useRef')` + * killing the renderer on the first lazily loaded component. + * + * Clearing the HTTP cache before loading the dev server keeps every dev + * session on exactly one optimizer generation. Packaged builds load file:// + * and never touch this path; localStorage/cookies are not part of the HTTP + * cache and survive. + * + * Structural interfaces instead of `electron` imports: the module stays + * loadable in plain node tests (same pattern as main-renderer-loader.ts). + */ +export interface DevRendererCacheSession { + clearCache(): Promise; +} + +export interface DevRendererCacheEntry { + readonly useDevServer: boolean; +} + +/** + * Clears the renderer session's HTTP cache when — and only when — the window + * is about to load a Vite dev server. Returns whether the cache was cleared. + * A clearCache failure downgrades to a warning: the duplicate-React crash it + * guards against is recoverable by reload, but a window that never opens is + * not. + */ +export async function clearDevRendererHttpCache( + session: DevRendererCacheSession, + rendererEntry: DevRendererCacheEntry, +): Promise { + if (!rendererEntry.useDevServer) return false; + try { + await session.clearCache(); + return true; + } catch (error) { + console.warn('[main] failed to clear dev renderer HTTP cache:', error); + return false; + } +} diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 9f8fbc1e84..9709e0eb02 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -29,6 +29,7 @@ import { BrowserViewManager } from './browser/view-manager.js'; import type { E2eFixture } from './e2e-fixture.js'; import { installMainWindowPermissionPolicy } from './main-window-permission-policy.js'; import { loadMainRenderer, resolveMainRendererEntry } from './main-renderer-loader.js'; +import { clearDevRendererHttpCache } from './main-renderer-dev-cache.js'; import { type MainRendererFrameIdentity, observeMainRendererProcessGone, @@ -522,6 +523,11 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main void writeSavedBounds(workspaceRoot, final); }); + // Dev-server cache hygiene (issue #4775) — see main-renderer-dev-cache.ts + // for why a stale immutable dep-chunk graph must never survive into a new + // dev session (duplicate React instances → null hook dispatcher crash). + await clearDevRendererHttpCache(mainWindow.webContents.session, rendererEntry); + await loadMainRenderer(mainWindow, rendererEntry); // PR-SHOW-AFTER-FIRST-COMMIT: reveal fallback. Start this budget only once