From 035493eb9a791ae0577e3d0003d54019435ca63b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 2 Oct 2026 05:37:36 +0000 Subject: [PATCH 1/4] fix(desktop): markdown in a new browser tab is readable in dark mode on first load A tab opened straight to a URL often painted its first page before the renderer registered it, so the opaque base from #14601 arrived too late and the page stayed white on white until a reload. Typing a URL into a fresh tab, clicking a chat link, and agent-opened tabs all take this path. Open the control session when the guest attaches, and send the opaque base before the other debugger setup commands. registerWebview reuses that session. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 64 ++++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 38 ++++++++++---- apps/desktop/src/window/DesktopWindow.ts | 1 + 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 95197d8916a0..782f1006d27c 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1691,6 +1691,70 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("sets the opaque base as soon as a guest attaches", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + const attach = vi.fn(); + const sendCommand = vi.fn(async () => undefined); + const wc = { + id: 44, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "http://localhost:5173/README.md", + getTitle: () => "README.md", + isLoading: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + once: (event: string, listener: () => void) => { + listeners.set(event, listener); + }, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => attach.mock.calls.length > 0, + attach, + detach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }; + fromId.mockReturnValue(wc as never); + + // The tab's first document can paint before the renderer registers the + // guest, so the opaque base has to be the first command on attach. + yield* manager.prepareWebview(wc as never); + expect(attach).toHaveBeenCalledTimes(1); + expect(sendCommand.mock.calls[0]).toEqual([ + "Emulation.setDefaultBackgroundColorOverride", + { color: { r: 255, g: 255, b: 255, a: 1 } }, + ]); + + yield* manager.createTab("tab_early"); + yield* manager.registerWebview("tab_early", 44); + yield* manager.setColorScheme("tab_early", "dark"); + expect(attach).toHaveBeenCalledTimes(1); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + + // A guest destroyed before a tab claims it releases its session. + listeners.get("destroyed")?.(); + yield* Effect.yieldNow; + expect(wc.debugger.detach).toHaveBeenCalledTimes(1); + }), + ), + ); + const makeAudioWebContents = (id: number) => { const listeners = new Map void>(); const setAudioMuted = vi.fn(); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4195bdffff6a..893cfd95783e 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1409,21 +1409,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("message", onMessage); wcDebugger.attach("1.3"); }); - yield* Effect.forEach( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], - (method) => - attemptPromise( - { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wcDebugger.sendCommand(method), - ), - { concurrency: "unbounded", discard: true }, - ); // Electron gives `` guests a transparent base background, and // Chromium only paints a dark canvas for dark color-scheme pages over an // opaque base. Without this, dark-scheme pages with no background of // their own (text/plain, e.g. .md files) render white text on white. // White matches the webview's existing white backing, so light pages look // the same; Chromium still swaps in its dark canvas for dark-scheme pages. + // Sent first because a document that paints before it arrives keeps the + // transparent base until its next load. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1431,6 +1424,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function color: { r: 255, g: 255, b: 255, a: 1 }, }), ); + yield* Effect.forEach( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wcDebugger.sendCommand(method), + ), + { concurrency: "unbounded", discard: true }, + ); return [ control, replaceMap(sessions, (copy) => { @@ -2415,6 +2417,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + // Called when a guest attaches to the window, before its first document + // paints. A tab opened straight to a URL often paints before the renderer + // gets to registerWebview, which would leave that page on the transparent + // base. registerWebview reuses the session opened here. + const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( + wc: Electron.WebContents, + ) { + const webContentsId = wc.id; + // A guest destroyed before any tab claims it has no other cleanup path. + wc.once("destroyed", () => { + runFork(detachControlSession(webContentsId)); + }); + yield* ensureControlSession(wc).pipe(Effect.ignore); + }); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -4675,6 +4692,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + prepareWebview, reapplyZoom, refresh, registerWebview, @@ -5011,6 +5029,7 @@ export class PreviewManager extends Context.Service< tabId: string, webContentsId: number, ) => Effect.Effect; + readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect; readonly navigate: (tabId: string, url: string) => Effect.Effect; readonly goBack: (tabId: string) => Effect.Effect; readonly goForward: (tabId: string) => Effect.Effect; @@ -5137,6 +5156,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { createTab: operations.createTab, closeTab: operations.closeTab, registerWebview: operations.registerWebview, + prepareWebview: operations.prepareWebview, navigate: operations.navigate, goBack: operations.goBack, goForward: operations.goForward, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 754de3caa727..aa5aa085a3ce 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -600,6 +600,7 @@ export const make = Effect.gen(function* () { installContextMenu(window, window.webContents); window.webContents.on("did-attach-webview", (_event, contents) => { installContextMenu(window, contents); + void runPromise(previewManager.prepareWebview(contents)); }); window.webContents.setWindowOpenHandler(({ url }) => { From 5236a7d5a04427e1205fb353b7d4eadda69a6afc Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 2 Oct 2026 06:05:32 +0000 Subject: [PATCH 2/4] test(desktop): stub prepareWebview in the window tests' preview manager Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/window/DesktopWindow.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5df400b90c44..1345c37bf687 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -314,6 +314,7 @@ function makeTestLayer(input: { Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, + prepareWebview: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), reapplyZoom: () => From 609b301c78c39ce15e3553716eb4415bfbd085c9 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 2 Oct 2026 06:21:38 +0000 Subject: [PATCH 3/4] test(desktop): destroy an unclaimed guest in the early-session test Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 89 +++++++++++++----------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 782f1006d27c..c9cc50f47b9e 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1694,47 +1694,52 @@ describe("PreviewManager", () => { effectIt.effect("sets the opaque base as soon as a guest attaches", () => withManager((manager) => Effect.gen(function* () { - const listeners = new Map void>(); - const attach = vi.fn(); - const sendCommand = vi.fn(async () => undefined); - const wc = { - id: 44, - isDestroyed: () => false, - isDevToolsOpened: () => false, - getType: () => "webview", - getURL: () => "http://localhost:5173/README.md", - getTitle: () => "README.md", - isLoading: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - once: (event: string, listener: () => void) => { - listeners.set(event, listener); - }, - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => attach.mock.calls.length > 0, - attach, - detach: vi.fn(), - sendCommand, + const makeGuest = (id: number) => { + const listeners = new Map void>(); + const attach = vi.fn(); + const detach = vi.fn(); + const sendCommand = vi.fn(async () => undefined); + const wc = { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "http://localhost:5173/README.md", + getTitle: () => "README.md", + isLoading: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), - }, + once: (event: string, listener: () => void) => { + listeners.set(event, listener); + }, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => attach.mock.calls.length > detach.mock.calls.length, + attach, + detach, + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }; + return { wc, attach, detach, sendCommand, listeners }; }; - fromId.mockReturnValue(wc as never); // The tab's first document can paint before the renderer registers the // guest, so the opaque base has to be the first command on attach. - yield* manager.prepareWebview(wc as never); - expect(attach).toHaveBeenCalledTimes(1); - expect(sendCommand.mock.calls[0]).toEqual([ + const claimed = makeGuest(44); + fromId.mockReturnValue(claimed.wc as never); + yield* manager.prepareWebview(claimed.wc as never); + expect(claimed.attach).toHaveBeenCalledTimes(1); + expect(claimed.sendCommand.mock.calls[0]).toEqual([ "Emulation.setDefaultBackgroundColorOverride", { color: { r: 255, g: 255, b: 255, a: 1 } }, ]); @@ -1742,15 +1747,19 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_early"); yield* manager.registerWebview("tab_early", 44); yield* manager.setColorScheme("tab_early", "dark"); - expect(attach).toHaveBeenCalledTimes(1); - expect(sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + expect(claimed.attach).toHaveBeenCalledTimes(1); + expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "dark" }], }); - // A guest destroyed before a tab claims it releases its session. - listeners.get("destroyed")?.(); + // A guest destroyed before any tab claims it releases its session. + const unclaimed = makeGuest(45); + yield* manager.prepareWebview(unclaimed.wc as never); + expect(unclaimed.attach).toHaveBeenCalledTimes(1); + unclaimed.listeners.get("destroyed")?.(); yield* Effect.yieldNow; - expect(wc.debugger.detach).toHaveBeenCalledTimes(1); + expect(unclaimed.detach).toHaveBeenCalledTimes(1); + expect(claimed.detach).not.toHaveBeenCalled(); }), ), ); From 5b5b627eea3d4be89c59babfaffd0bcde858543a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 2 Oct 2026 12:49:31 +0000 Subject: [PATCH 4/4] fix(desktop): skip guests destroyed while waiting to open a control session A guest destroyed while another guest held the control-session lock threw from isDevToolsOpened once it got the lock. prepareWebview runs detached from the attach event, so that defect became an unhandled rejection. Check for a destroyed guest first, and keep any attach-time failure inside prepareWebview. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 134 +++++++++++++++-------- apps/desktop/src/preview/Manager.ts | 22 +++- 2 files changed, 110 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c9cc50f47b9e..9adaeaaa448b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1691,53 +1691,69 @@ describe("PreviewManager", () => { ), ); + const makeAttachingGuest = (id: number) => { + const listeners = new Map void>(); + const attach = vi.fn(); + const detach = vi.fn(); + const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( + async () => undefined, + ); + let destroyed = false; + const wc = { + id, + isDestroyed: () => destroyed, + isDevToolsOpened: () => { + // Electron throws from native methods once a WebContents is destroyed. + if (destroyed) throw new TypeError("Object has been destroyed"); + return false; + }, + getType: () => "webview", + getURL: () => "http://localhost:5173/README.md", + getTitle: () => "README.md", + isLoading: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + once: (event: string, listener: () => void) => { + listeners.set(event, listener); + }, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => attach.mock.calls.length > detach.mock.calls.length, + attach, + detach, + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }; + return { + wc: wc as unknown as Electron.WebContents, + attach, + detach, + sendCommand, + destroy: () => { + destroyed = true; + listeners.get("destroyed")?.(); + }, + }; + }; + effectIt.effect("sets the opaque base as soon as a guest attaches", () => withManager((manager) => Effect.gen(function* () { - const makeGuest = (id: number) => { - const listeners = new Map void>(); - const attach = vi.fn(); - const detach = vi.fn(); - const sendCommand = vi.fn(async () => undefined); - const wc = { - id, - isDestroyed: () => false, - isDevToolsOpened: () => false, - getType: () => "webview", - getURL: () => "http://localhost:5173/README.md", - getTitle: () => "README.md", - isLoading: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - once: (event: string, listener: () => void) => { - listeners.set(event, listener); - }, - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => attach.mock.calls.length > detach.mock.calls.length, - attach, - detach, - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - }; - return { wc, attach, detach, sendCommand, listeners }; - }; - // The tab's first document can paint before the renderer registers the // guest, so the opaque base has to be the first command on attach. - const claimed = makeGuest(44); - fromId.mockReturnValue(claimed.wc as never); - yield* manager.prepareWebview(claimed.wc as never); + const claimed = makeAttachingGuest(44); + fromId.mockReturnValue(claimed.wc); + yield* manager.prepareWebview(claimed.wc); expect(claimed.attach).toHaveBeenCalledTimes(1); expect(claimed.sendCommand.mock.calls[0]).toEqual([ "Emulation.setDefaultBackgroundColorOverride", @@ -1753,10 +1769,10 @@ describe("PreviewManager", () => { }); // A guest destroyed before any tab claims it releases its session. - const unclaimed = makeGuest(45); - yield* manager.prepareWebview(unclaimed.wc as never); + const unclaimed = makeAttachingGuest(45); + yield* manager.prepareWebview(unclaimed.wc); expect(unclaimed.attach).toHaveBeenCalledTimes(1); - unclaimed.listeners.get("destroyed")?.(); + unclaimed.destroy(); yield* Effect.yieldNow; expect(unclaimed.detach).toHaveBeenCalledTimes(1); expect(claimed.detach).not.toHaveBeenCalled(); @@ -1764,6 +1780,34 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("skips a guest destroyed while another guest's session opens", () => + withManager((manager) => + Effect.gen(function* () { + const slow = makeAttachingGuest(46); + let releaseSlow = () => {}; + const slowCommand = new Promise((resolve) => { + releaseSlow = resolve; + }); + slow.sendCommand.mockImplementation(() => slowCommand); + const queued = makeAttachingGuest(47); + + const slowFiber = yield* manager + .prepareWebview(slow.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + const queuedFiber = yield* manager + .prepareWebview(queued.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + queued.destroy(); + releaseSlow(); + + expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); + expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); + expect(slow.attach).toHaveBeenCalledTimes(1); + expect(queued.attach).not.toHaveBeenCalled(); + }), + ), + ); + const makeAudioWebContents = (id: number) => { const listeners = new Map void>(); const setAudioMuted = vi.fn(); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 893cfd95783e..f7b5046a7fed 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1303,6 +1303,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function > => { const existing = sessions.get(wc.id); if (existing) return Effect.succeed([existing, sessions] as const); + // A guest can be destroyed while it waits for this lock, and its native + // methods throw once it is. + if (wc.isDestroyed()) { + return Effect.fail( + new PreviewOperationError({ + operation: "ensureControlSession", + webContentsId: wc.id, + cause: new Error("WebContents was destroyed"), + }), + ); + } if (wc.isDevToolsOpened()) { return Effect.fail( new PreviewAutomationDevToolsOpenError({ @@ -2429,7 +2440,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.once("destroyed", () => { runFork(detachControlSession(webContentsId)); }); - yield* ensureControlSession(wc).pipe(Effect.ignore); + // Runs detached from the attach event, so nothing may escape. registerWebview + // opens the session again if this one did not. + yield* ensureControlSession(wc).pipe( + Effect.catchCause((cause) => + Effect.logDebug("Preview webview control session was not opened on attach.", { + webContentsId, + cause, + }), + ), + ); }); const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {