authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-08-18 17:22:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-18 17:22:37-07:00
logfe281c5408164250da6688de6fc71ac12d8a9d8f
tree2323b64420a0971700549543003e7dce1f15b7e4
parentbcfd485869a5eb9e97c6d281ac839c1cd7c1bd99
signature Signed by PGP key B5690EEEBB952194

fix(desktop): throttle hidden preview rendering (#7445)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com>

4 files changed, 327 insertions(+), 30 deletions(-)

apps/desktop/src/preview/Manager.test.ts+225
......@@ -1797,6 +1797,216 @@ describe("PreviewManager", () => {
17971797 ),
17981798 );
17991799
1800 effectIt.effect("keeps window unthrottled until the final frame capture stops", () =>
1801 withManager((manager) =>
1802 Effect.gen(function* () {
1803 const setBackgroundThrottling = vi.fn();
1804 const capturePage = vi.fn(async () => ({
1805 toJPEG: () => Buffer.from("recording-frame"),
1806 getSize: () => ({ width: 1280, height: 720 }),
1807 }));
1808 const webContentsById = new Map([
1809 [41, makeTestPreviewWebContents(capturePage, 41)],
1810 [42, makeTestPreviewWebContents(capturePage, 42)],
1811 ]);
1812 fromId.mockImplementation((id) =>
1813 id === undefined ? null : (webContentsById.get(id) ?? null),
1814 );
1815
1816 yield* manager.createTab("tab_capture_throttling_1");
1817 yield* manager.createTab("tab_capture_throttling_2");
1818 yield* manager.registerWebview("tab_capture_throttling_1", 41);
1819 yield* manager.registerWebview("tab_capture_throttling_2", 42);
1820 yield* manager.setMainWindow({
1821 isDestroyed: () => false,
1822 once: vi.fn(),
1823 webContents: { setBackgroundThrottling },
1824 } as never);
1825
1826 yield* manager.startRecording("tab_capture_throttling_1");
1827 yield* manager.startRecording("tab_capture_throttling_2");
1828 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
1829
1830 yield* manager.stopRecording("tab_capture_throttling_1");
1831 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
1832
1833 yield* manager.stopRecording("tab_capture_throttling_2");
1834 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
1835 }),
1836 ),
1837 );
1838
1839 effectIt.effect("does not commit failed starts and retries throttle restoration", () =>
1840 withManager((manager) =>
1841 Effect.gen(function* () {
1842 const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>();
1843 const capturePage = vi.fn(async () => ({
1844 toJPEG: () => Buffer.from("recording-frame"),
1845 getSize: () => ({ width: 1280, height: 720 }),
1846 }));
1847 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
1848
1849 yield* manager.createTab("tab_capture_throttling_failure");
1850 yield* manager.registerWebview("tab_capture_throttling_failure", 42);
1851 yield* manager.setMainWindow({
1852 isDestroyed: () => false,
1853 once: vi.fn(),
1854 webContents: { setBackgroundThrottling },
1855 } as never);
1856
1857 setBackgroundThrottling.mockImplementationOnce(() => {
1858 throw new Error("start throttling update failed");
1859 });
1860 const failedStart = yield* Effect.exit(
1861 manager.startRecording("tab_capture_throttling_failure"),
1862 );
1863 expect(Exit.isFailure(failedStart)).toBe(true);
1864
1865 yield* manager.startRecording("tab_capture_throttling_failure");
1866 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false]]);
1867
1868 setBackgroundThrottling.mockImplementationOnce(() => {
1869 throw new Error("stop throttling update failed");
1870 });
1871 yield* manager.stopRecording("tab_capture_throttling_failure");
1872 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true], [true]]);
1873
1874 yield* manager.startRecording("tab_capture_throttling_failure");
1875 yield* manager.stopRecording("tab_capture_throttling_failure");
1876 expect(setBackgroundThrottling.mock.calls).toEqual([
1877 [false],
1878 [false],
1879 [true],
1880 [true],
1881 [false],
1882 [true],
1883 ]);
1884 }),
1885 ),
1886 );
1887
1888 effectIt.effect("does not publish a replacement window when capture reconciliation fails", () =>
1889 withManager((manager) =>
1890 Effect.gen(function* () {
1891 const setBackgroundThrottling = vi.fn(() => {
1892 throw new Error("replacement throttling update failed");
1893 });
1894 const capturePage = vi.fn(async () => ({
1895 toJPEG: () => Buffer.from("recording-frame"),
1896 getSize: () => ({ width: 1280, height: 720 }),
1897 }));
1898 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
1899
1900 yield* manager.createTab("tab_capture_replacement_failure");
1901 yield* manager.registerWebview("tab_capture_replacement_failure", 42);
1902 yield* manager.startRecording("tab_capture_replacement_failure");
1903
1904 const failedReplacement = yield* Effect.exit(
1905 manager.setMainWindow({
1906 isDestroyed: () => false,
1907 once: vi.fn(),
1908 webContents: { setBackgroundThrottling },
1909 } as never),
1910 );
1911 expect(Exit.isFailure(failedReplacement)).toBe(true);
1912
1913 yield* manager.stopRecording("tab_capture_replacement_failure");
1914 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
1915 }),
1916 ),
1917 );
1918
1919 effectIt.effect("ignores close events from replaced main windows", () =>
1920 withManager((manager) =>
1921 Effect.gen(function* () {
1922 let closeFirstWindow: (() => void) | undefined;
1923 const firstWindowThrottling = vi.fn();
1924 const replacementWindowThrottling = vi.fn();
1925 const capturePage = vi.fn(async () => ({
1926 toJPEG: () => Buffer.from("recording-frame"),
1927 getSize: () => ({ width: 1280, height: 720 }),
1928 }));
1929 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
1930
1931 yield* manager.createTab("tab_replaced_window_close");
1932 yield* manager.registerWebview("tab_replaced_window_close", 42);
1933 yield* manager.setMainWindow({
1934 isDestroyed: () => false,
1935 once: vi.fn((event: string, listener: () => void) => {
1936 if (event === "closed") closeFirstWindow = listener;
1937 }),
1938 webContents: { setBackgroundThrottling: firstWindowThrottling },
1939 } as never);
1940 yield* manager.setMainWindow({
1941 isDestroyed: () => false,
1942 once: vi.fn(),
1943 webContents: { setBackgroundThrottling: replacementWindowThrottling },
1944 } as never);
1945
1946 closeFirstWindow?.();
1947 yield* manager.startRecording("tab_replaced_window_close");
1948 expect(firstWindowThrottling).not.toHaveBeenCalled();
1949 expect(replacementWindowThrottling.mock.calls).toEqual([[false]]);
1950 yield* manager.stopRecording("tab_replaced_window_close");
1951 expect(replacementWindowThrottling.mock.calls).toEqual([[false], [true]]);
1952 }),
1953 ),
1954 );
1955
1956 effectIt.effect("releases frame capture when the main window closes", () =>
1957 withManager((manager) =>
1958 Effect.gen(function* () {
1959 let closeMainWindow: (() => void) | undefined;
1960 const firstWindowThrottling = vi.fn();
1961 const replacementWindowThrottling = vi.fn();
1962 const capturePage = vi.fn(async () => ({
1963 toJPEG: () => Buffer.from("recording-frame"),
1964 getSize: () => ({ width: 1280, height: 720 }),
1965 }));
1966 const webContentsById = new Map([
1967 [42, makeTestPreviewWebContents(capturePage, 42)],
1968 [43, makeTestPreviewWebContents(capturePage, 43)],
1969 ]);
1970 fromId.mockImplementation((id) =>
1971 id === undefined ? null : (webContentsById.get(id) ?? null),
1972 );
1973
1974 yield* manager.createTab("tab_window_close_recording");
1975 yield* manager.createTab("tab_window_close_race");
1976 yield* manager.registerWebview("tab_window_close_recording", 42);
1977 yield* manager.registerWebview("tab_window_close_race", 43);
1978 yield* manager.setMainWindow({
1979 isDestroyed: () => false,
1980 once: vi.fn((event: string, listener: () => void) => {
1981 if (event === "closed") closeMainWindow = listener;
1982 }),
1983 webContents: { setBackgroundThrottling: firstWindowThrottling },
1984 } as never);
1985 yield* manager.startRecording("tab_window_close_recording");
1986 expect(firstWindowThrottling.mock.calls).toEqual([[false]]);
1987
1988 closeMainWindow?.();
1989 const racedStart = yield* Effect.exit(manager.startRecording("tab_window_close_race"));
1990 expect(Exit.isFailure(racedStart)).toBe(true);
1991 if (Exit.isFailure(racedStart)) {
1992 expect(Option.getOrThrow(Cause.findErrorOption(racedStart.cause))).toMatchObject({
1993 _tag: "PreviewMainWindowClosedError",
1994 tabId: "tab_window_close_race",
1995 });
1996 }
1997 yield* Effect.yieldNow;
1998 yield* Effect.yieldNow;
1999
2000 yield* manager.setMainWindow({
2001 isDestroyed: () => false,
2002 once: vi.fn(),
2003 webContents: { setBackgroundThrottling: replacementWindowThrottling },
2004 } as never);
2005 expect(replacementWindowThrottling).not.toHaveBeenCalled();
2006 }),
2007 ),
2008 );
2009
18002010 effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () =>
18012011 withManager((manager) =>
18022012 Effect.gen(function* () {
......@@ -2106,6 +2316,8 @@ describe("PreviewManager", () => {
21062316 effectIt.effect("shares background frame capture between recording and picture-in-picture", () =>
21072317 withManager((manager) =>
21082318 Effect.gen(function* () {
2319 const setBackgroundThrottling = vi.fn();
2320 const mainWindowWebContents = { setBackgroundThrottling };
21092321 const jpeg = Buffer.from("shared-preview-frame");
21102322 const capturePage = vi.fn(async () => ({
21112323 toJPEG: () => jpeg,
......@@ -2113,6 +2325,7 @@ describe("PreviewManager", () => {
21132325 }));
21142326 fromId.mockReturnValue({
21152327 id: 42,
2328 hostWebContents: mainWindowWebContents,
21162329 isDestroyed: () => false,
21172330 getType: () => "webview",
21182331 getURL: () => "https://example.com",
......@@ -2165,6 +2378,12 @@ describe("PreviewManager", () => {
21652378 const states: PreviewManager.PreviewTabState[] = [];
21662379 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
21672380
2381 yield* manager.setMainWindow({
2382 isDestroyed: () => false,
2383 once: vi.fn(),
2384 webContents: mainWindowWebContents,
2385 } as never);
2386
21682387 yield* manager.subscribeStateChanges((_tabId, state) =>
21692388 Effect.sync(() => {
21702389 states.push(state);
......@@ -2179,6 +2398,7 @@ describe("PreviewManager", () => {
21792398 yield* manager.registerWebview("tab_pip", 42);
21802399 yield* manager.openPictureInPicture("tab_pip");
21812400
2401 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
21822402 expect(browserWindowConstructor).toHaveBeenCalledWith(
21832403 expect.objectContaining({
21842404 alwaysOnTop: true,
......@@ -2224,6 +2444,7 @@ describe("PreviewManager", () => {
22242444 expect(recordingFrames).toHaveLength(1);
22252445
22262446 yield* manager.stopRecording("tab_pip");
2447 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
22272448 const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length;
22282449 yield* TestClock.adjust(100);
22292450 expect(capturePage).toHaveBeenCalledTimes(3);
......@@ -2232,7 +2453,11 @@ describe("PreviewManager", () => {
22322453 );
22332454 expect(recordingFrames).toHaveLength(1);
22342455
2456 setBackgroundThrottling.mockImplementationOnce(() => {
2457 throw new Error("picture-in-picture throttling restore failed");
2458 });
22352459 yield* manager.closePictureInPicture("tab_pip");
2460 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [true]]);
22362461 expect(pictureInPictureWindow.close).toHaveBeenCalledOnce();
22372462 expect(states.at(-1)?.pictureInPicture).toBe(false);
22382463 const capturesAfterClose = capturePage.mock.calls.length;
apps/desktop/src/preview/Manager.ts+101-28
......@@ -526,6 +526,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
526526 const pictureInPictureAspectRatiosRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
527527 const pictureInPictureMutationSemaphore = yield* Semaphore.make(1);
528528 const closingTabIdsRef = yield* Ref.make<ReadonlySet<string>>(new Set());
529 let frameCaptureWindowOpen = true;
530 let currentMainWindow: BrowserWindow | undefined;
531 let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined;
529532 const tabLifecycleLocks = new Map<
530533 string,
531534 { readonly semaphore: Semaphore.Semaphore; users: number }
......@@ -583,35 +586,67 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
583586 ),
584587 );
585588 });
589 const setWindowBackgroundThrottling = Effect.fnUntraced(function* (
590 window: BrowserWindow,
591 enabled: boolean,
592 ) {
593 if (window.isDestroyed()) return;
594 yield* attempt({ operation: "frameCapture.setBackgroundThrottling" }, () =>
595 window.webContents.setBackgroundThrottling(enabled),
596 );
597 });
598 const setFrameCaptureBackgroundThrottling = Effect.fnUntraced(function* (enabled: boolean) {
599 const mainWindow = yield* Ref.get(mainWindowRef);
600 if (Option.isNone(mainWindow)) return;
601 yield* setWindowBackgroundThrottling(mainWindow.value, enabled);
602 });
586603 const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* (
587604 tabId: string,
588605 consumer: FrameCaptureConsumer,
589606 ) {
590 const captureScope = yield* SynchronizedRef.modify(frameCaptureSessionsRef, (sessions) => {
591 const current = sessions.get(tabId);
592 if (!current || !current.consumers.has(consumer)) {
593 return [undefined, sessions] as const;
594 }
595 const consumers = new Set(current.consumers);
596 consumers.delete(consumer);
597 if (consumers.size > 0) {
598 return [
599 undefined,
600 replaceMap(sessions, (copy) => {
601 copy.set(tabId, { ...current, consumers });
602 }),
603 ] as const;
604 }
605 return [
606 current.scope,
607 replaceMap(sessions, (copy) => {
607 yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) =>
608 Effect.gen(function* () {
609 const current = sessions.get(tabId);
610 if (!current || !current.consumers.has(consumer)) {
611 return [undefined, sessions] as const;
612 }
613 const consumers = new Set(current.consumers);
614 consumers.delete(consumer);
615 if (consumers.size > 0) {
616 return [
617 undefined,
618 replaceMap(sessions, (copy) => {
619 copy.set(tabId, { ...current, consumers });
620 }),
621 ] as const;
622 }
623 const remainingSessions = replaceMap(sessions, (copy) => {
608624 copy.delete(tabId);
609 }),
610 ] as const;
625 });
626 if (remainingSessions.size === 0) {
627 yield* setFrameCaptureBackgroundThrottling(true).pipe(
628 Effect.retry({ times: 2 }),
629 Effect.catch((error) =>
630 Effect.logWarning("Failed to restore preview frame capture throttling.", { error }),
631 ),
632 );
633 }
634 return [current.scope, remainingSessions] as const;
635 }),
636 ).pipe(
637 Effect.flatMap((captureScope) =>
638 captureScope ? Scope.close(captureScope, Exit.void).pipe(Effect.ignore) : Effect.void,
639 ),
640 Effect.uninterruptible,
641 );
642 });
643
644 const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () {
645 const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef);
646 yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), {
647 concurrency: "unbounded",
648 discard: true,
611649 });
612 if (captureScope) {
613 yield* Scope.close(captureScope, Exit.void).pipe(Effect.ignore);
614 }
615650 });
616651
617652 const deliverEvent = (
......@@ -1691,10 +1726,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16911726 const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* (
16921727 window: BrowserWindow,
16931728 ) {
1694 yield* Ref.set(mainWindowRef, Option.some(window));
1695 window.once("closed", () => {
1696 runFork(closeAllPictureInPicture());
1697 });
1729 if (mainWindowCleanupFiber) {
1730 yield* Fiber.join(mainWindowCleanupFiber);
1731 mainWindowCleanupFiber = undefined;
1732 }
1733 yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) =>
1734 Effect.gen(function* () {
1735 if (sessions.size > 0) {
1736 yield* setWindowBackgroundThrottling(window, false);
1737 }
1738 yield* Ref.set(mainWindowRef, Option.some(window));
1739 currentMainWindow = window;
1740 frameCaptureWindowOpen = true;
1741 window.once("closed", () => {
1742 if (currentMainWindow !== window) return;
1743 currentMainWindow = undefined;
1744 frameCaptureWindowOpen = false;
1745 mainWindowCleanupFiber = runFork(
1746 Effect.all([closeAllPictureInPicture(), stopAllRecordings()], {
1747 concurrency: "unbounded",
1748 discard: true,
1749 }).pipe(Effect.ignore),
1750 );
1751 });
1752 return [undefined, sessions] as const;
1753 }),
1754 ).pipe(Effect.uninterruptible);
16981755 });
16991756
17001757 const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (
......@@ -2597,6 +2654,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25972654 );
25982655 const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => {
25992656 return Effect.gen(function* () {
2657 if (!frameCaptureWindowOpen) {
2658 return yield* new PreviewMainWindowClosedError({ tabId });
2659 }
26002660 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
26012661 if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) {
26022662 return yield* new PreviewTabNotFoundError({ tabId });
......@@ -2616,6 +2676,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26162676 }),
26172677 ] as const;
26182678 }
2679 if (sessions.size === 0) {
2680 yield* setFrameCaptureBackgroundThrottling(false);
2681 }
26192682 const scope = yield* Scope.fork(parentScope, "sequential");
26202683 yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
26212684 return [
......@@ -2628,7 +2691,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26282691 }),
26292692 ] as const;
26302693 });
2631 });
2694 }).pipe(Effect.uninterruptible);
26322695 if (!created) return;
26332696 yield* capturePreviewFrame(tabId).pipe(
26342697 Effect.catch((error) =>
......@@ -3724,6 +3787,15 @@ export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass<P
37243787 }
37253788}
37263789
3790export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass<PreviewMainWindowClosedError>()(
3791 "PreviewMainWindowClosedError",
3792 { tabId: Schema.String },
3793) {
3794 override get message(): string {
3795 return `Cannot start preview frame capture while the main window is closed: ${this.tabId}`;
3796 }
3797}
3798
37273799export class PreviewOperationError extends Schema.TaggedErrorClass<PreviewOperationError>()(
37283800 "PreviewOperationError",
37293801 {
......@@ -3930,6 +4002,7 @@ export const PreviewManagerError = Schema.Union([
39304002 PreviewTabNotFoundError,
39314003 PreviewWebContentsNotFoundError,
39324004 PreviewWebviewNotInitializedError,
4005 PreviewMainWindowClosedError,
39334006 PreviewOperationError,
39344007 PreviewArtifactPathOutsideDirectoryError,
39354008 PreviewArtifactImageLoadError,
apps/desktop/src/window/DesktopWindow.test.ts+1-1
......@@ -455,7 +455,7 @@ describe("DesktopWindow", () => {
455455 assert.isUndefined(createdWindowOptions[0]?.x);
456456 assert.isUndefined(createdWindowOptions[0]?.y);
457457 assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor);
458 assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling);
458 assert.isUndefined(createdWindowOptions[0]?.webPreferences?.backgroundThrottling);
459459 assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);
460460 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]);
461461 assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);
apps/desktop/src/window/DesktopWindow.ts-1
......@@ -359,7 +359,6 @@ export const make = Effect.gen(function* () {
359359 ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform),
360360 webPreferences: {
361361 preload: environment.preloadPath,
362 backgroundThrottling: false,
363362 contextIsolation: true,
364363 nodeIntegration: false,
365364 sandbox: true,