From ab4bfda360eabf7886dddddc2658a7c1d5c9ee1e Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Thu, 3 Sep 2026 17:37:32 +0900 Subject: [PATCH] perf(desktop): resolve the renderer bundle once per window, not twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createMainWindow walked the entire renderer generation twice before it could call loadWindowUrl: const rendererIndex = DEV_SERVER ? null : resolveRendererIndex() const tornAssets = rendererIndex ? missingRendererAssets(rendererIndex) : [] resolveRendererIndex already computes exactly that list while choosing the copy — it needs it to decide whether a copy is torn — and then throws it away. missingRendererAssets is a BFS that readFileSync's every present chunk whole and regex-scans it for the inline __vite__mapDeps table, so on a release tree it is not a stat walk: measured against the real apps/desktop/dist (252 chunks, 28.7 MiB of JS), one walk is 162 readFileSync calls reading 28.23 MiB, 576 existsSync calls, and 56.6 ms median (min 55.8, 9 reps, warm page cache, darwin-arm64). Both walks run synchronously on the main thread before the window gets its URL. Return the list alongside the index. resolveRendererIndexWithMissing() carries the existing body and hands back { index, missing }; the path-only resolveRendererIndex() stays as a one-line wrapper so the nine other call sites are untouched. The primary-window path takes one resolution. Semantics are unchanged in every branch: the same candidate is chosen, the same log lines are emitted, and the missing list always describes the copy actually returned. The all-copies-torn branch now reuses the first candidate's list, captured on the first loop iteration, rather than recomputing it for present[0] — recomputing there would have reintroduced the second walk in exactly the case that matters most, and using the loop's last value would have described a bundle we do not load. Net effect on every primary-window boot: one fewer full walk, so 162 fewer readFileSync calls, 28.23 MiB less synchronous reading, 288 fewer existsSync calls, and ~57 ms of main-thread blocking removed before loadURL. The win lands on packaged and --prod launches; DEV_SERVER skips the walk entirely, so `vite dev` is unaffected. --- apps/desktop/electron/main.ts | 40 +++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 737fa37e50..5384902b67 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4764,7 +4764,15 @@ function resolveWebDist() { return fallback } -function resolveRendererIndex() { +// Same resolution as resolveRendererIndex, but also hands back the missing +// asset list already computed for the copy it chose. The primary-window path +// needs BOTH, and re-deriving the list means walking the whole renderer +// generation a second time: missingRendererAssets follows index.html's +// modulepreload refs and then every chunk's inline __vite__mapDeps table, so +// on a release build it reads ~28 MiB across ~160 files synchronously on the +// main thread — measured ~49 ms per walk, twice before loadWindowUrl(). +// Callers that only need the path keep using resolveRendererIndex below. +function resolveRendererIndexWithMissing(): { index: string; missing: string[] } { const asarIndex = path.join(APP_ROOT, 'dist', 'index.html') const webDistIndex = path.join(resolveWebDist(), 'index.html') @@ -4787,11 +4795,20 @@ function resolveRendererIndex() { // first lazy import with "Failed to fetch dynamically imported module" and // every restart reloads the same torn copy. Prefer a copy whose modules are // all present, so the intact generation heals the boot by itself. + // Remember the FIRST candidate's list: if every copy turns out to be torn we + // load present[0], and its list is already in hand — recomputing it there + // would reintroduce the very second walk this function exists to avoid. + let firstMissing: string[] | null = null + for (const candidate of present) { const missing = missingRendererAssets(candidate) if (missing.length === 0) { - return candidate + return { index: candidate, missing: [] } + } + + if (firstMissing === null) { + firstMissing = missing } rememberLog( @@ -4811,7 +4828,9 @@ function resolveRendererIndex() { `Repair with: hermes desktop --force-build` ) - return present[0] + // present[0]'s own list, captured on the first loop iteration — never the + // last candidate's, which would describe a bundle we are not loading. + return { index: present[0], missing: firstMissing ?? [] } } // Nothing on disk. A packaged build with no renderer bundle blank-pages with @@ -4823,7 +4842,13 @@ function resolveRendererIndex() { `Rebuild with: hermes desktop --force-build` ) - return candidates[0] + return { index: candidates[0], missing: [] } +} + +// Path-only accessor: unchanged behaviour for the window loaders that do not +// need the torn-asset list. +function resolveRendererIndex() { + return resolveRendererIndexWithMissing().index } // True when `dir` lives inside the packaged app bundle / install tree. @@ -14824,8 +14849,11 @@ function createWindow() { // copies; here we refuse to load one into the PRIMARY window and put the // visible repair page in it instead. The Reload button re-attempts the // bundle in case the file lock cleared since boot. - const rendererIndex = DEV_SERVER ? null : resolveRendererIndex() - const tornAssets = rendererIndex ? missingRendererAssets(rendererIndex) : [] + // One resolution, not two: resolveRendererIndexWithMissing already computed + // this copy's missing list while choosing it. + const resolvedRenderer = DEV_SERVER ? null : resolveRendererIndexWithMissing() + const rendererIndex = resolvedRenderer?.index ?? null + const tornAssets = resolvedRenderer?.missing ?? [] if (!DEV_SERVER && rendererIndex && tornAssets.length > 0) { rememberLog(