authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-03 05:01:36+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 05:01:36+05:30
log7ff2eabf56d30af15a8ac86b0eb46013a9e910ee
tree4169e40cd44b7dca703288b3eaf17747bfbaac15
parentfd7ee2c30a7eeef910c99e7bc92e944526555db7
signature Signed by PGP key B5690EEEBB952194

fix(desktop): markdown no longer loads invisible in new dark-mode browser tabs (#14727)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 168 insertions(+), 9 deletions(-)

apps/desktop/src/preview/Manager.test.ts+117
...@@ -1691,6 +1691,123 @@ describe("PreviewManager", () => {...@@ -1691,6 +1691,123 @@ describe("PreviewManager", () => {
1691 ),1691 ),
1692 );1692 );
16931693
1694 const makeAttachingGuest = (id: number) => {
1695 const listeners = new Map<string, () => void>();
1696 const attach = vi.fn();
1697 const detach = vi.fn();
1698 const sendCommand = vi.fn<(method: string, params?: unknown) => Promise<unknown>>(
1699 async () => undefined,
1700 );
1701 let destroyed = false;
1702 const wc = {
1703 id,
1704 isDestroyed: () => destroyed,
1705 isDevToolsOpened: () => {
1706 // Electron throws from native methods once a WebContents is destroyed.
1707 if (destroyed) throw new TypeError("Object has been destroyed");
1708 return false;
1709 },
1710 getType: () => "webview",
1711 getURL: () => "http://localhost:5173/README.md",
1712 getTitle: () => "README.md",
1713 isLoading: () => true,
1714 getZoomFactor: () => 1,
1715 setZoomFactor: vi.fn(),
1716 setAudioMuted: vi.fn(),
1717 isCurrentlyAudible: () => false,
1718 on: vi.fn(),
1719 off: vi.fn(),
1720 once: (event: string, listener: () => void) => {
1721 listeners.set(event, listener);
1722 },
1723 ipc: { on: vi.fn(), off: vi.fn() },
1724 send: webviewSend,
1725 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1726 setIgnoreMenuShortcuts: vi.fn(),
1727 setWindowOpenHandler: vi.fn(),
1728 debugger: {
1729 isAttached: () => attach.mock.calls.length > detach.mock.calls.length,
1730 attach,
1731 detach,
1732 sendCommand,
1733 on: vi.fn(),
1734 off: vi.fn(),
1735 },
1736 };
1737 return {
1738 wc: wc as unknown as Electron.WebContents,
1739 attach,
1740 detach,
1741 sendCommand,
1742 destroy: () => {
1743 destroyed = true;
1744 listeners.get("destroyed")?.();
1745 },
1746 };
1747 };
1748
1749 effectIt.effect("sets the opaque base as soon as a guest attaches", () =>
1750 withManager((manager) =>
1751 Effect.gen(function* () {
1752 // The tab's first document can paint before the renderer registers the
1753 // guest, so the opaque base has to be the first command on attach.
1754 const claimed = makeAttachingGuest(44);
1755 fromId.mockReturnValue(claimed.wc);
1756 yield* manager.prepareWebview(claimed.wc);
1757 expect(claimed.attach).toHaveBeenCalledTimes(1);
1758 expect(claimed.sendCommand.mock.calls[0]).toEqual([
1759 "Emulation.setDefaultBackgroundColorOverride",
1760 { color: { r: 255, g: 255, b: 255, a: 1 } },
1761 ]);
1762
1763 yield* manager.createTab("tab_early");
1764 yield* manager.registerWebview("tab_early", 44);
1765 yield* manager.setColorScheme("tab_early", "dark");
1766 expect(claimed.attach).toHaveBeenCalledTimes(1);
1767 expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
1768 features: [{ name: "prefers-color-scheme", value: "dark" }],
1769 });
1770
1771 // A guest destroyed before any tab claims it releases its session.
1772 const unclaimed = makeAttachingGuest(45);
1773 yield* manager.prepareWebview(unclaimed.wc);
1774 expect(unclaimed.attach).toHaveBeenCalledTimes(1);
1775 unclaimed.destroy();
1776 yield* Effect.yieldNow;
1777 expect(unclaimed.detach).toHaveBeenCalledTimes(1);
1778 expect(claimed.detach).not.toHaveBeenCalled();
1779 }),
1780 ),
1781 );
1782
1783 effectIt.effect("skips a guest destroyed while another guest's session opens", () =>
1784 withManager((manager) =>
1785 Effect.gen(function* () {
1786 const slow = makeAttachingGuest(46);
1787 let releaseSlow = () => {};
1788 const slowCommand = new Promise<void>((resolve) => {
1789 releaseSlow = resolve;
1790 });
1791 slow.sendCommand.mockImplementation(() => slowCommand);
1792 const queued = makeAttachingGuest(47);
1793
1794 const slowFiber = yield* manager
1795 .prepareWebview(slow.wc)
1796 .pipe(Effect.forkChild({ startImmediately: true }));
1797 const queuedFiber = yield* manager
1798 .prepareWebview(queued.wc)
1799 .pipe(Effect.forkChild({ startImmediately: true }));
1800 queued.destroy();
1801 releaseSlow();
1802
1803 expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true);
1804 expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true);
1805 expect(slow.attach).toHaveBeenCalledTimes(1);
1806 expect(queued.attach).not.toHaveBeenCalled();
1807 }),
1808 ),
1809 );
1810
1694 const makeAudioWebContents = (id: number) => {1811 const makeAudioWebContents = (id: number) => {
1695 const listeners = new Map<string, (...args: never[]) => void>();1812 const listeners = new Map<string, (...args: never[]) => void>();
1696 const setAudioMuted = vi.fn();1813 const setAudioMuted = vi.fn();
apps/desktop/src/preview/Manager.ts+49-9
...@@ -1303,6 +1303,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1303,6 +1303,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1303 > => {1303 > => {
1304 const existing = sessions.get(wc.id);1304 const existing = sessions.get(wc.id);
1305 if (existing) return Effect.succeed([existing, sessions] as const);1305 if (existing) return Effect.succeed([existing, sessions] as const);
1306 // A guest can be destroyed while it waits for this lock, and its native
1307 // methods throw once it is.
1308 if (wc.isDestroyed()) {
1309 return Effect.fail(
1310 new PreviewOperationError({
1311 operation: "ensureControlSession",
1312 webContentsId: wc.id,
1313 cause: new Error("WebContents was destroyed"),
1314 }),
1315 );
1316 }
1306 if (wc.isDevToolsOpened()) {1317 if (wc.isDevToolsOpened()) {
1307 return Effect.fail(1318 return Effect.fail(
1308 new PreviewAutomationDevToolsOpenError({1319 new PreviewAutomationDevToolsOpenError({
...@@ -1409,21 +1420,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1409,21 +1420,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1409 wcDebugger.on("message", onMessage);1420 wcDebugger.on("message", onMessage);
1410 wcDebugger.attach("1.3");1421 wcDebugger.attach("1.3");
1411 });1422 });
1412 yield* Effect.forEach(
1413 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"],
1414 (method) =>
1415 attemptPromise(
1416 { operation: `initializeDebugger.${method}`, webContentsId: wc.id },
1417 () => wcDebugger.sendCommand(method),
1418 ),
1419 { concurrency: "unbounded", discard: true },
1420 );
1421 // Electron gives `<webview>` guests a transparent base background, and1423 // Electron gives `<webview>` guests a transparent base background, and
1422 // Chromium only paints a dark canvas for dark color-scheme pages over an1424 // Chromium only paints a dark canvas for dark color-scheme pages over an
1423 // opaque base. Without this, dark-scheme pages with no background of1425 // opaque base. Without this, dark-scheme pages with no background of
1424 // their own (text/plain, e.g. .md files) render white text on white.1426 // their own (text/plain, e.g. .md files) render white text on white.
1425 // White matches the webview's existing white backing, so light pages look1427 // White matches the webview's existing white backing, so light pages look
1426 // the same; Chromium still swaps in its dark canvas for dark-scheme pages.1428 // the same; Chromium still swaps in its dark canvas for dark-scheme pages.
1429 // Sent first because a document that paints before it arrives keeps the
1430 // transparent base until its next load.
1427 yield* attemptPromise(1431 yield* attemptPromise(
1428 { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },1432 { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },
1429 () =>1433 () =>
...@@ -1431,6 +1435,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1431,6 +1435,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1431 color: { r: 255, g: 255, b: 255, a: 1 },1435 color: { r: 255, g: 255, b: 255, a: 1 },
1432 }),1436 }),
1433 );1437 );
1438 yield* Effect.forEach(
1439 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"],
1440 (method) =>
1441 attemptPromise(
1442 { operation: `initializeDebugger.${method}`, webContentsId: wc.id },
1443 () => wcDebugger.sendCommand(method),
1444 ),
1445 { concurrency: "unbounded", discard: true },
1446 );
1434 return [1447 return [
1435 control,1448 control,
1436 replaceMap(sessions, (copy) => {1449 replaceMap(sessions, (copy) => {
...@@ -2415,6 +2428,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2415,6 +2428,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2415 );2428 );
2416 });2429 });
24172430
2431 // Called when a guest attaches to the window, before its first document
2432 // paints. A tab opened straight to a URL often paints before the renderer
2433 // gets to registerWebview, which would leave that page on the transparent
2434 // base. registerWebview reuses the session opened here.
2435 const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* (
2436 wc: Electron.WebContents,
2437 ) {
2438 const webContentsId = wc.id;
2439 // A guest destroyed before any tab claims it has no other cleanup path.
2440 wc.once("destroyed", () => {
2441 runFork(detachControlSession(webContentsId));
2442 });
2443 // Runs detached from the attach event, so nothing may escape. registerWebview
2444 // opens the session again if this one did not.
2445 yield* ensureControlSession(wc).pipe(
2446 Effect.catchCause((cause) =>
2447 Effect.logDebug("Preview webview control session was not opened on attach.", {
2448 webContentsId,
2449 cause,
2450 }),
2451 ),
2452 );
2453 });
2454
2418 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {2455 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
2419 const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () =>2456 const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () =>
2420 normalizePreviewUrl(rawUrl),2457 normalizePreviewUrl(rawUrl),
...@@ -4675,6 +4712,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -4675,6 +4712,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
4675 openPictureInPicture,4712 openPictureInPicture,
4676 openDevTools,4713 openDevTools,
4677 pickElement,4714 pickElement,
4715 prepareWebview,
4678 reapplyZoom,4716 reapplyZoom,
4679 refresh,4717 refresh,
4680 registerWebview,4718 registerWebview,
...@@ -5011,6 +5049,7 @@ export class PreviewManager extends Context.Service<...@@ -5011,6 +5049,7 @@ export class PreviewManager extends Context.Service<
5011 tabId: string,5049 tabId: string,
5012 webContentsId: number,5050 webContentsId: number,
5013 ) => Effect.Effect<void, PreviewManagerError>;5051 ) => Effect.Effect<void, PreviewManagerError>;
5052 readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect<void>;
5014 readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>;5053 readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>;
5015 readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>;5054 readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
5016 readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>;5055 readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
...@@ -5137,6 +5176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -5137,6 +5176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
5137 createTab: operations.createTab,5176 createTab: operations.createTab,
5138 closeTab: operations.closeTab,5177 closeTab: operations.closeTab,
5139 registerWebview: operations.registerWebview,5178 registerWebview: operations.registerWebview,
5179 prepareWebview: operations.prepareWebview,
5140 navigate: operations.navigate,5180 navigate: operations.navigate,
5141 goBack: operations.goBack,5181 goBack: operations.goBack,
5142 goForward: operations.goForward,5182 goForward: operations.goForward,
apps/desktop/src/window/DesktopWindow.test.ts+1
...@@ -314,6 +314,7 @@ function makeTestLayer(input: {...@@ -314,6 +314,7 @@ function makeTestLayer(input: {
314 Layer.mock(PreviewManager.PreviewManager)({314 Layer.mock(PreviewManager.PreviewManager)({
315 getBrowserSession: () => Effect.succeed({} as Electron.Session),315 getBrowserSession: () => Effect.succeed({} as Electron.Session),
316 setMainWindow: () => Effect.void,316 setMainWindow: () => Effect.void,
317 prepareWebview: () => Effect.void,
317 isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),318 isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
318 getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"),319 getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"),
319 reapplyZoom: () =>320 reapplyZoom: () =>
apps/desktop/src/window/DesktopWindow.ts+1
...@@ -600,6 +600,7 @@ export const make = Effect.gen(function* () {...@@ -600,6 +600,7 @@ export const make = Effect.gen(function* () {
600 installContextMenu(window, window.webContents);600 installContextMenu(window, window.webContents);
601 window.webContents.on("did-attach-webview", (_event, contents) => {601 window.webContents.on("did-attach-webview", (_event, contents) => {
602 installContextMenu(window, contents);602 installContextMenu(window, contents);
603 void runPromise(previewManager.prepareWebview(contents));
603 });604 });
604605
605 window.webContents.setWindowOpenHandler(({ url }) => {606 window.webContents.setWindowOpenHandler(({ url }) => {