From 3fb841eea9e859acefb00d719c576b3db1c9864a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 20:12:02 +0800 Subject: [PATCH] fix(desktop): keep dev renderer on one Vite dep-optimizer generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Settings in a dev session could kill the renderer with "Cannot read properties of null (reading 'useRef')": the page executed two optimizer generations at once — react/react-dom chunks resurrected from the persistent Electron HTTP cache (immutable `?v=` entries whose per-dep labels survive re-optimization commits) next to freshly transformed sources importing today's react copy. Two React instances leave the hook dispatcher null on one of them, and the first hook call in any lazily loaded component crashes. - main-window: clear the session HTTP cache before loading a dev server (main-renderer-dev-cache.ts, packaged file:// builds untouched) - dev.mjs: warm the renderer entry and wait for the crawl to settle before launching Electron, so the initial optimizer commit lands before the window loads Fixes #4775 Generated-by: Maka --- apps/desktop/scripts/dev.mjs | 14 ++++ .../__tests__/main-renderer-dev-cache.test.ts | 48 +++++++++++++ .../src/main/main-renderer-dev-cache.ts | 68 +++++++++++++++++++ apps/desktop/src/main/main-window.ts | 6 ++ 4 files changed, 136 insertions(+) create mode 100644 apps/desktop/src/main/__tests__/main-renderer-dev-cache.test.ts create mode 100644 apps/desktop/src/main/main-renderer-dev-cache.ts 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