perf(desktop): resolve the renderer bundle once per window, not twice

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.
This commit is contained in:
John Paul Soliva
2026-09-03 17:37:32 +09:00
committed by Teknium
parent a22c731744
commit ab4bfda360
+34 -6
View File
@@ -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(