From 7ff2eabf56d30af15a8ac86b0eb46013a9e910ee Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:01:36 +0530 Subject: [PATCH] fix(desktop): markdown no longer loads invisible in new dark-mode browser tabs (#14727) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 117 ++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 58 +++++++-- apps/desktop/src/window/DesktopWindow.test.ts | 1 + apps/desktop/src/window/DesktopWindow.ts | 1 + 4 files changed, 168 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 95197d8916a05e9a29dcc58f01391c856ef245e3..9adaeaaa448b6f011f1a6412832cbbc82f5efeb6 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1691,6 +1691,123 @@ 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* () { + // 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 = 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", + { 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(claimed.attach).toHaveBeenCalledTimes(1); + expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + + // A guest destroyed before any tab claims it releases its session. + const unclaimed = makeAttachingGuest(45); + yield* manager.prepareWebview(unclaimed.wc); + expect(unclaimed.attach).toHaveBeenCalledTimes(1); + unclaimed.destroy(); + yield* Effect.yieldNow; + expect(unclaimed.detach).toHaveBeenCalledTimes(1); + expect(claimed.detach).not.toHaveBeenCalled(); + }), + ), + ); + + 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 4195bdffff6a6672ff43eed9edae8799036f6cab..f7b5046a7fed9fc65567dfbfe8b3d1b90966c17b 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({ @@ -1409,21 +1420,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 +1435,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 +2428,30 @@ 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)); + }); + // 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) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -4675,6 +4712,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + prepareWebview, reapplyZoom, refresh, registerWebview, @@ -5011,6 +5049,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 +5176,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.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5df400b90c44d5b5defe43f08c3ffd678ff9a208..1345c37bf687935f25c1d9d51b701a066577d1f0 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: () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 754de3caa727e74019510e84d709281232142b65..aa5aa085a3ce1d71b2195a0c9b4b510d68c4b496 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 }) => { -- 2.54.0