From 3671ea154af67735e1c46c36ccda5e6ab2139864 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:08:40 +1000 Subject: [PATCH 1/2] perf(desktop): serve the renderer's first assets before evaluating the main bundle The oc:// handler runs on the main thread, so a ~100 ms module evaluation used to sit between the renderer and its HTML. The entry module now waits for the renderer's initial burst of asset requests to be answered (40 ms quiet, 400 ms cap) before importing the bundle. --- packages/desktop/src/main/index.ts | 16 +++++++--- packages/desktop/src/main/windows/protocol.ts | 31 +++++++++++++++++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index b3d2cb1b1076..625be02548d6 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -2,7 +2,8 @@ import { marks } from "./lifecycle/marks" import { app } from "electron" import { acquireApplicationLock, configureApplication } from "./lifecycle/configure" -import { createEarlyWindow } from "./windows/early" +import { createEarlyWindow } from "./windows/early" +import { rendererAssetsServed } from "./windows/protocol" import { registerRendererScheme } from "./windows/scheme" // This module stays small on purpose. Electron holds the ready event until the entry module has @@ -14,10 +15,15 @@ if (acquireApplicationLock()) { registerRendererScheme() // Window first, then the bundle: starting the import before ready delays ready itself, because the // module graph evaluates on the same thread Chromium needs to finish initialising. - void app.whenReady().then(() => { - marks.ready = Date.now() - createEarlyWindow() - marks.window = Date.now() + void app.whenReady().then(async () => { + marks.ready = Date.now() + createEarlyWindow() + marks.window = Date.now() + // The window's renderer is already loading. Its HTML and preloaded chunks are served from this + // thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates. + if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 }) + marks.served = Date.now() return import("./desktop") }) } + diff --git a/packages/desktop/src/main/windows/protocol.ts b/packages/desktop/src/main/windows/protocol.ts index 95cad665e7ce..05c6fdc46965 100644 --- a/packages/desktop/src/main/windows/protocol.ts +++ b/packages/desktop/src/main/windows/protocol.ts @@ -14,11 +14,39 @@ export function setProtocolReporter(reporter: ProtocolReport) { report = reporter } +// Requests in flight and when the last one arrived. The entry module holds the main bundle back +// until the renderer's initial burst of asset requests has been answered, because this handler +// runs on the main thread and a 100 ms module evaluation would otherwise sit between the renderer +// and its HTML. +let inflight = 0 +let served = 0 +let lastRequest = 0 + +export function rendererAssetsServed(options: { quietMs: number; capMs: number }) { + const start = Date.now() + return new Promise((resolve) => { + const check = () => { + const now = Date.now() + if (now - start >= options.capMs) return resolve() + if (served > 0 && inflight === 0 && now - lastRequest >= options.quietMs) return resolve() + setTimeout(check, 5) + } + check() + }) +} + export function registerRendererProtocol(rendererRoot: string) { if (protocol.isProtocolHandled(rendererProtocol)) return protocol.handle(rendererProtocol, async (request) => { - return serve(request, rendererRoot) + inflight++ + lastRequest = Date.now() + try { + return await serve(request, rendererRoot) + } finally { + inflight-- + served++ + } }) } @@ -60,4 +88,3 @@ function addDocumentPolicy(response: Response, file: string) { headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy) return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) } - From 5e1aa4fad99d7d2a634014efc3e03f88e8e6b486 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:08:42 +1000 Subject: [PATCH 2/2] perf(desktop): bundle the renderer's initial module graph as one chunk Everything statically reachable from the renderer entry is grouped into one chunk (codeSplitting.groups walking importers), so startup fetches one file from the oc:// handler instead of 47 served one main-thread turn each. Lazy routes keep their own chunks. --- packages/desktop/electron.vite.config.ts | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 6dd68067f338..2db653a6f383 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -28,6 +28,29 @@ const sentry = }) : false +// Every module the entry reaches through static imports lands in one chunk. Automatic splitting +// otherwise fragments the initial graph into ~50 files shared with lazy routes, and each file costs +// the renderer a main-thread request round trip through the main process before first paint. +type ChunkingContext = { getModuleInfo(id: string): { isEntry: boolean; importers: readonly string[] } | null } +const initialGraph = new WeakMap>() +function inInitialGraph(id: string, ctx: ChunkingContext) { + const memo = initialGraph.get(ctx) ?? new Map() + initialGraph.set(ctx, memo) + const visit = (id: string, path: Set): boolean => { + const known = memo.get(id) + if (known !== undefined) return known + if (path.has(id)) return false + const info = ctx.getModuleInfo(id) + if (!info) return false + path.add(id) + const result = info.isEntry || info.importers.some((importer) => visit(importer, path)) + path.delete(id) + memo.set(id, result) + return result + } + return visit(id, new Set()) +} + export default defineConfig(({ command }) => ({ main: { resolve: { @@ -110,6 +133,11 @@ const require = __cjs_mod__.createRequire(import.meta.url); input: { main: "src/renderer/index.html", }, + output: { + codeSplitting: { + groups: [{ name: (id, ctx) => (inInitialGraph(id, ctx) ? "app" : null), priority: 10 }], + }, + }, }, }, },