From 5fdfcc7a80f50d18d1dc3b5515dc5aa6552cfe65 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:29:02 +1000 Subject: [PATCH] perf(desktop): hand the window its persisted stores before the first render (#50113) --- packages/desktop/src/main/index.ts | 2 + packages/desktop/src/main/storage/index.ts | 2 + packages/desktop/src/main/storage/snapshot.ts | 56 +++++++++++++++++++ packages/desktop/src/main/windows/index.ts | 9 +-- packages/desktop/src/preload/index.ts | 13 ++++- packages/desktop/src/preload/types.ts | 2 + packages/desktop/src/renderer/api.ts | 13 ++++- packages/desktop/src/shared/ipc-transport.ts | 14 +++++ 8 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 packages/desktop/src/main/storage/snapshot.ts diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index b989ad73523..9a1d0e2b4a9 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -3,6 +3,7 @@ import { marks } from "./lifecycle/marks" import { app } from "electron" import { acquireApplicationLock, configureApplication } from "./lifecycle/configure" import { startSidecarProbe } from "./service/sidecar-probe" +import { registerStorageSnapshotHandler } from "./storage/snapshot" import { createEarlyWindow } from "./windows/early" import { rendererAssetsServed } from "./windows/protocol" import { registerRendererScheme } from "./windows/scheme" @@ -18,6 +19,7 @@ if (acquireApplicationLock()) { // module graph evaluates on the same thread Chromium needs to finish initialising. void app.whenReady().then(async () => { marks.ready = Date.now() + registerStorageSnapshotHandler() createEarlyWindow() marks.window = Date.now() startSidecarProbe() diff --git a/packages/desktop/src/main/storage/index.ts b/packages/desktop/src/main/storage/index.ts index 3baa8792620..05a5ce3fc01 100644 --- a/packages/desktop/src/main/storage/index.ts +++ b/packages/desktop/src/main/storage/index.ts @@ -4,6 +4,7 @@ import { app, BrowserWindow } from "electron" import { Context, Effect, Layer, Path } from "effect" import { marks } from "../lifecycle/marks" import { openDatabase } from "./database" +import { setStorageSnapshotProvider } from "./snapshot" import { createDraftStore } from "./drafts" import { importLegacyStores } from "./legacy" import { createStateStore } from "./state" @@ -41,6 +42,7 @@ export const layer = Layer.effect( storage.close() }), ) + setStorageSnapshotProvider((names) => Object.fromEntries(names.map((name) => [name, storage.state.items(name)]))) marks.storage = Date.now() return Service.of(storage) }), diff --git a/packages/desktop/src/main/storage/snapshot.ts b/packages/desktop/src/main/storage/snapshot.ts new file mode 100644 index 00000000000..629e9b74d07 --- /dev/null +++ b/packages/desktop/src/main/storage/snapshot.ts @@ -0,0 +1,56 @@ +import { existsSync, readdirSync } from "node:fs" +import path from "node:path" +import { DatabaseSync } from "node:sqlite" +import { app, ipcMain } from "electron" +import { StorageSnapshotChannel, type StorageSnapshot } from "../../shared/ipc-transport" +import { isRendererUrl } from "../windows/scheme" + +// A window's preload asks for the namespaces its shell reads before the page runs, so the first +// render is the hydrated one. Until the storage layer is up (the first window asks before it exists) +// the answer comes from the database file; the layer takes over so later windows see queued writes. +type Provider = (names: ReadonlyArray) => StorageSnapshot + +let provider: Provider = readFromDisk + +export function setStorageSnapshotProvider(next: Provider) { + provider = next +} + +export function registerStorageSnapshotHandler() { + ipcMain.handle(StorageSnapshotChannel, (event, names: unknown): StorageSnapshot => { + if (!isRendererUrl(event.senderFrame?.url)) return {} + if (!Array.isArray(names)) return {} + return provider(names.filter((name): name is string => typeof name === "string")) + }) +} + +// Nothing has been written in this process yet, so every namespace is at revision 0, as the +// storage layer would report before its first update. Legacy electron-store files still waiting +// to be imported would make the database stale for this launch, so the renderer asks the layer then. +function readFromDisk(names: ReadonlyArray): StorageSnapshot { + const userData = app.getPath("userData") + const file = path.join(userData, "drafts.sqlite") + if (!existsSync(file)) return {} + if (readdirSync(userData).some((name) => name === "default.dat" || /^opencode\..+\.dat$/.test(name))) return {} + try { + const db = new DatabaseSync(file) + try { + const rows = db.prepare("SELECT key, value FROM state WHERE name = ?") + return Object.fromEntries( + names.map((name) => [ + name, + { + items: Object.fromEntries( + (rows.all(name) as { key: string; value: string }[]).map((row) => [row.key, row.value]), + ), + revision: 0, + }, + ]), + ) + } finally { + db.close() + } + } catch { + return {} + } +} diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 8663b9b5481..a0fd38417ff 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -7,6 +7,7 @@ import { DesktopPaths } from "../paths" import { DesktopStorage } from "../storage" import { getStore } from "../storage/store" import { WINDOW_IDS_KEY } from "../storage/keys" +import { windowDataFile } from "../../shared/ipc-transport" import { windowArguments } from "./bootstrap" import { getBackgroundColor, @@ -189,12 +190,4 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { return { create, restore } }) -// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state -// namespace the renderer persists this window's tabs under. -function windowDataFile(id: string) { - return `opencode.window.${safeWindowID(id)}.dat` -} -function safeWindowID(id: string) { - return id.replace(/[^a-zA-Z0-9._-]/g, "-") -} diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index e3181ff973c..9bc1da769f1 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,5 +1,11 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" -import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport" +import { + DragCancelEvent, + IpcTransportPort, + StorageSnapshotChannel, + storageSnapshotNames, + type StorageSnapshot, +} from "../shared/ipc-transport" import { windowBootstrapFromArguments } from "../shared/window-bootstrap" ipcRenderer.on(IpcTransportPort, (event) => { @@ -10,9 +16,14 @@ ipcRenderer.on(IpcTransportPort, (event) => { ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent))) const bootstrap = windowBootstrapFromArguments(process.argv) +// Asked before the page runs, so the stores the shell reads are hydrated on the first render. +const storageSnapshot: Promise = ipcRenderer + .invoke(StorageSnapshotChannel, storageSnapshotNames(bootstrap.id)) + .catch(() => ({})) contextBridge.exposeInMainWorld("electron", { windowID: bootstrap.id, bootstrap, + storageSnapshot, getPathForFile: (file: File) => webUtils.getPathForFile(file), }) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 85be1519706..4d60ef346ec 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,7 +1,9 @@ +import type { StorageSnapshot } from "../shared/ipc-transport" import type { WindowBootstrap } from "../shared/window-bootstrap" export type ElectronNative = { windowID: string bootstrap: WindowBootstrap + storageSnapshot: Promise getPathForFile(file: File): string } diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index 4d1f5c6dd00..a1b83542520 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -22,6 +22,9 @@ const updaterHandler = (state: UpdaterState) => { updaterCallbacks.forEach((callback) => callback(state)) } +// One renderer-side copy: the bridge clones on every crossing, so consumption is tracked here. +const seeded = window.electron.storageSnapshot.then((snapshot) => new Map(Object.entries(snapshot))) + export const api: ElectronAPI = { awaitInitialization: () => invoke("AppAwaitInitialization"), reconnectService: () => invoke("AppReconnectService"), @@ -99,7 +102,15 @@ export const api: ElectronAPI = { invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }), checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }), resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }), - storeItems: (name) => invoke("StorageItems", { name }).then(mutable), + // The first read of a namespace the preload already fetched is served from that snapshot; later + // reads (a window re-opening a namespace) go to the main process as usual. + storeItems: (name) => + seeded.then((snapshot) => { + const item = snapshot.get(name) + if (!item) return invoke("StorageItems", { name }).then(mutable) + snapshot.delete(name) + return item + }), storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }), storeClear: (name) => invoke("StorageClear", { name }), onStoreChanged: (cb) => diff --git a/packages/desktop/src/shared/ipc-transport.ts b/packages/desktop/src/shared/ipc-transport.ts index 2a75f2540e7..78d52309bb7 100644 --- a/packages/desktop/src/shared/ipc-transport.ts +++ b/packages/desktop/src/shared/ipc-transport.ts @@ -1,2 +1,16 @@ export const IpcTransportPort = "desktop-rpc-port" export const DragCancelEvent = "opencode:drag-cancel" +export const StorageSnapshotChannel = "desktop-storage-snapshot" + +export type StorageSnapshot = Record; revision: number }> + +// The namespaces a window reads while its shell mounts. The preload asks for them before the page +// runs so the first render already has them; mirrors windowStorage() in +// packages/app/src/runtime/persistence/storage.ts. +export function storageSnapshotNames(windowID: string) { + return ["opencode.global.dat", "default.dat", windowDataFile(windowID)] +} + +export function windowDataFile(id: string) { + return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat` +}