authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-29 20:31:33+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 10:31:33-07:00
log72c44a847c0a76f33b0d21f47548125b7032ec35
tree9e4c2d2a8c7a4b767129a303ac61aa7c9bf52c32
parent660cddd3bc9801e089afcabba11c62f41aeac5c3
signature Signed by PGP key B5690EEEBB952194

perf(desktop): skip duplicate browser updates (#8018)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 333 insertions(+), 11 deletions(-)

apps/desktop/src/preview/Manager.test.ts+226-6
...@@ -332,8 +332,18 @@ const settle = function* (until: () => boolean) {...@@ -332,8 +332,18 @@ const settle = function* (until: () => boolean) {
332332
333const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {333const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {
334 const listeners = new Map<string, () => void>();334 const listeners = new Map<string, () => void>();
335 const webContentsListeners = new Map<string, () => void>();
335 const send = vi.fn();336 const send = vi.fn();
336 let destroyed = false;337 let destroyed = false;
338 const webContents = {
339 on: vi.fn((event: string, listener: () => void) => {
340 webContentsListeners.set(event, listener);
341 }),
342 off: vi.fn((event: string) => {
343 webContentsListeners.delete(event);
344 }),
345 send,
346 };
337 const pictureInPictureWindow = {347 const pictureInPictureWindow = {
338 isDestroyed: vi.fn(() => destroyed),348 isDestroyed: vi.fn(() => destroyed),
339 once: vi.fn((event: string, listener: () => void) => {349 once: vi.fn((event: string, listener: () => void) => {
...@@ -353,11 +363,12 @@ const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async ()...@@ -353,11 +363,12 @@ const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async ()
353 destroyed = true;363 destroyed = true;
354 listeners.get("closed")?.();364 listeners.get("closed")?.();
355 }),365 }),
356 webContents: {366 get webContents() {
357 send,367 if (destroyed) throw new Error("Picture-in-picture window is closed.");
368 return webContents;
358 },369 },
359 };370 };
360 return { pictureInPictureWindow, send };371 return { pictureInPictureWindow, send, webContentsListeners };
361};372};
362373
363describe("PreviewManager", () => {374describe("PreviewManager", () => {
...@@ -2413,6 +2424,8 @@ describe("PreviewManager", () => {...@@ -2413,6 +2424,8 @@ describe("PreviewManager", () => {
2413 pictureInPictureListeners.get("closed")?.();2424 pictureInPictureListeners.get("closed")?.();
2414 }),2425 }),
2415 webContents: {2426 webContents: {
2427 on: vi.fn(),
2428 off: vi.fn(),
2416 send: pictureInPictureSend,2429 send: pictureInPictureSend,
2417 },2430 },
2418 };2431 };
...@@ -2478,6 +2491,7 @@ describe("PreviewManager", () => {...@@ -2478,6 +2491,7 @@ describe("PreviewManager", () => {
2478 );2491 );
2479 expect(states.at(-1)?.pictureInPicture).toBe(true);2492 expect(states.at(-1)?.pictureInPicture).toBe(true);
2480 expect(capturePage).toHaveBeenCalledOnce();2493 expect(capturePage).toHaveBeenCalledOnce();
2494 const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length;
24812495
2482 yield* manager.startRecording("tab_pip");2496 yield* manager.startRecording("tab_pip");
2483 expect(capturePage).toHaveBeenCalledOnce();2497 expect(capturePage).toHaveBeenCalledOnce();
...@@ -2485,6 +2499,7 @@ describe("PreviewManager", () => {...@@ -2485,6 +2499,7 @@ describe("PreviewManager", () => {
24852499
2486 yield* TestClock.adjust(100);2500 yield* TestClock.adjust(100);
2487 expect(capturePage).toHaveBeenCalledTimes(2);2501 expect(capturePage).toHaveBeenCalledTimes(2);
2502 expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording);
2488 expect(recordingFrames).toHaveLength(1);2503 expect(recordingFrames).toHaveLength(1);
24892504
2490 yield* manager.stopRecording("tab_pip");2505 yield* manager.stopRecording("tab_pip");
...@@ -2492,9 +2507,7 @@ describe("PreviewManager", () => {...@@ -2492,9 +2507,7 @@ describe("PreviewManager", () => {
2492 const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length;2507 const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length;
2493 yield* TestClock.adjust(100);2508 yield* TestClock.adjust(100);
2494 expect(capturePage).toHaveBeenCalledTimes(3);2509 expect(capturePage).toHaveBeenCalledTimes(3);
2495 expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan(2510 expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick);
2496 framesBeforePictureInPictureOnlyTick,
2497 );
2498 expect(recordingFrames).toHaveLength(1);2511 expect(recordingFrames).toHaveLength(1);
24992512
2500 setBackgroundThrottling.mockImplementationOnce(() => {2513 setBackgroundThrottling.mockImplementationOnce(() => {
...@@ -2511,6 +2524,213 @@ describe("PreviewManager", () => {...@@ -2511,6 +2524,213 @@ describe("PreviewManager", () => {
2511 ),2524 ),
2512 );2525 );
25132526
2527 effectIt.effect("starts picture-in-picture without an extra recording capture", () =>
2528 withManager((manager) =>
2529 Effect.gen(function* () {
2530 const jpeg = Buffer.from("shared-preview-frame");
2531 const capturePage = vi.fn(async () => ({
2532 toJPEG: () => jpeg,
2533 getSize: () => ({ width: 1280, height: 720 }),
2534 }));
2535 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2536 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
2537 browserWindowConstructor.mockImplementation(function () {
2538 return pictureInPictureWindow;
2539 });
2540 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
2541
2542 yield* manager.subscribeRecordingFrames((frame) =>
2543 Effect.sync(() => {
2544 recordingFrames.push(frame);
2545 }),
2546 );
2547 yield* manager.createTab("tab_recording_then_pip");
2548 yield* manager.registerWebview("tab_recording_then_pip", 42);
2549 yield* manager.startRecording("tab_recording_then_pip");
2550
2551 expect(recordingFrames).toHaveLength(1);
2552 yield* manager.openPictureInPicture("tab_recording_then_pip");
2553 expect(capturePage).toHaveBeenCalledOnce();
2554 expect(send).not.toHaveBeenCalled();
2555
2556 yield* TestClock.adjust(100);
2557
2558 expect(capturePage).toHaveBeenCalledTimes(2);
2559 expect(recordingFrames).toHaveLength(2);
2560 expect(send).toHaveBeenCalledOnce();
2561 yield* manager.closePictureInPicture("tab_recording_then_pip");
2562 yield* manager.stopRecording("tab_recording_then_pip");
2563 }),
2564 ),
2565 );
2566
2567 effectIt.effect("stops frame capture when the native picture-in-picture window closes", () =>
2568 withManager((manager) =>
2569 Effect.gen(function* () {
2570 const capturePage = vi.fn(async () => ({
2571 toJPEG: () => Buffer.from("native-close-preview-frame"),
2572 getSize: () => ({ width: 1280, height: 720 }),
2573 }));
2574 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2575 const { pictureInPictureWindow } = makeTestPictureInPictureWindow();
2576 browserWindowConstructor.mockImplementation(function () {
2577 return pictureInPictureWindow;
2578 });
2579 const states: PreviewManager.PreviewTabState[] = [];
2580 yield* manager.subscribeStateChanges((_tabId, state) =>
2581 Effect.sync(() => {
2582 states.push(state);
2583 }),
2584 );
2585
2586 yield* manager.createTab("tab_native_pip_close");
2587 yield* manager.registerWebview("tab_native_pip_close", 42);
2588 yield* manager.openPictureInPicture("tab_native_pip_close");
2589
2590 pictureInPictureWindow.close();
2591 yield* settle(() => states.at(-1)?.pictureInPicture === false);
2592
2593 expect(states.at(-1)?.pictureInPicture).toBe(false);
2594 const capturesAfterClose = capturePage.mock.calls.length;
2595 yield* TestClock.adjust(200);
2596 expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose);
2597 }),
2598 ),
2599 );
2600
2601 effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () =>
2602 withManager((manager) =>
2603 Effect.gen(function* () {
2604 const jpeg = Buffer.from("retry-preview-frame");
2605 const capturePage = vi.fn(async () => ({
2606 toJPEG: () => jpeg,
2607 getSize: () => ({ width: 1280, height: 720 }),
2608 }));
2609 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2610 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
2611 send.mockImplementationOnce(() => {
2612 throw new Error("picture-in-picture delivery failed");
2613 });
2614 browserWindowConstructor.mockImplementation(function () {
2615 return pictureInPictureWindow;
2616 });
2617
2618 yield* manager.createTab("tab_pip_delivery_retry");
2619 yield* manager.registerWebview("tab_pip_delivery_retry", 42);
2620 yield* manager.openPictureInPicture("tab_pip_delivery_retry");
2621 expect(send).toHaveBeenCalledOnce();
2622
2623 yield* TestClock.adjust(100);
2624
2625 expect(capturePage).toHaveBeenCalledTimes(2);
2626 expect(send).toHaveBeenCalledTimes(2);
2627 yield* manager.closePictureInPicture("tab_pip_delivery_retry");
2628 }),
2629 ),
2630 );
2631
2632 effectIt.effect("replays an unchanged picture-in-picture frame after its renderer reloads", () =>
2633 withManager((manager) =>
2634 Effect.gen(function* () {
2635 const jpeg = Buffer.from("reloaded-preview-frame");
2636 const capturePage = vi.fn(async () => ({
2637 toJPEG: () => jpeg,
2638 getSize: () => ({ width: 1280, height: 720 }),
2639 }));
2640 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2641 const { pictureInPictureWindow, send, webContentsListeners } =
2642 makeTestPictureInPictureWindow();
2643 browserWindowConstructor.mockImplementation(function () {
2644 return pictureInPictureWindow;
2645 });
2646
2647 yield* manager.createTab("tab_pip_reload");
2648 yield* manager.registerWebview("tab_pip_reload", 42);
2649 yield* manager.openPictureInPicture("tab_pip_reload");
2650 expect(send).toHaveBeenCalledOnce();
2651
2652 webContentsListeners.get("did-finish-load")?.();
2653 yield* TestClock.adjust(100);
2654
2655 expect(send).toHaveBeenCalledTimes(2);
2656 yield* manager.closePictureInPicture("tab_pip_reload");
2657 }),
2658 ),
2659 );
2660
2661 effectIt.effect("keeps recording cadence when pixels remain unchanged", () =>
2662 withManager((manager) =>
2663 Effect.gen(function* () {
2664 const unchanged = Buffer.from("unchanged-preview-frame");
2665 const capturePage = vi.fn(async () => ({
2666 toJPEG: () => unchanged,
2667 getSize: () => ({ width: 1280, height: 720 }),
2668 }));
2669 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2670 const frames: DesktopPreviewRecordingFrame[] = [];
2671
2672 yield* manager.subscribeRecordingFrames((frame) =>
2673 Effect.sync(() => {
2674 frames.push(frame);
2675 }),
2676 );
2677 yield* manager.createTab("tab_unchanged_frame");
2678 yield* manager.registerWebview("tab_unchanged_frame", 42);
2679 yield* manager.startRecording("tab_unchanged_frame");
2680
2681 expect(frames.map((frame) => frame.data)).toEqual([unchanged.toString("base64")]);
2682
2683 yield* TestClock.adjust(100);
2684
2685 expect(capturePage).toHaveBeenCalledTimes(2);
2686 expect(frames.map((frame) => frame.data)).toEqual([
2687 unchanged.toString("base64"),
2688 unchanged.toString("base64"),
2689 ]);
2690
2691 yield* TestClock.adjust(100);
2692
2693 expect(capturePage).toHaveBeenCalledTimes(3);
2694 expect(frames.map((frame) => frame.data)).toEqual([
2695 unchanged.toString("base64"),
2696 unchanged.toString("base64"),
2697 unchanged.toString("base64"),
2698 ]);
2699 yield* manager.stopRecording("tab_unchanged_frame");
2700 }),
2701 ),
2702 );
2703
2704 effectIt.effect("retries recording delivery when pixels remain unchanged", () =>
2705 withManager((manager) =>
2706 Effect.gen(function* () {
2707 const jpeg = Buffer.from("retry-recording-frame");
2708 const capturePage = vi.fn(async () => ({
2709 toJPEG: () => jpeg,
2710 getSize: () => ({ width: 1280, height: 720 }),
2711 }));
2712 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2713 let deliveries = 0;
2714
2715 yield* manager.subscribeRecordingFrames(() =>
2716 Effect.sync(() => {
2717 deliveries += 1;
2718 if (deliveries === 1) throw new Error("recording delivery failed");
2719 }),
2720 );
2721 yield* manager.createTab("tab_recording_delivery_retry");
2722 yield* manager.registerWebview("tab_recording_delivery_retry", 42);
2723 yield* manager.startRecording("tab_recording_delivery_retry");
2724 expect(deliveries).toBe(1);
2725
2726 yield* TestClock.adjust(100);
2727
2728 expect(deliveries).toBe(2);
2729 yield* manager.stopRecording("tab_recording_delivery_retry");
2730 }),
2731 ),
2732 );
2733
2514 effectIt.effect("retries a cold hidden-tab capture without dropping recording", () =>2734 effectIt.effect("retries a cold hidden-tab capture without dropping recording", () =>
2515 withManager((manager) =>2735 withManager((manager) =>
2516 Effect.gen(function* () {2736 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+47-5
...@@ -381,6 +381,7 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording";...@@ -381,6 +381,7 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording";
381interface FrameCaptureSession {381interface FrameCaptureSession {
382 readonly scope: Scope.Closeable;382 readonly scope: Scope.Closeable;
383 readonly consumers: ReadonlySet<FrameCaptureConsumer>;383 readonly consumers: ReadonlySet<FrameCaptureConsumer>;
384 readonly lastPictureInPictureFrame: Buffer | null;
384}385}
385386
386interface PictureInPictureSession {387interface PictureInPictureSession {
...@@ -671,7 +672,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -671,7 +672,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
671 return [672 return [
672 undefined,673 undefined,
673 replaceMap(sessions, (copy) => {674 replaceMap(sessions, (copy) => {
674 copy.set(tabId, { ...current, consumers });675 copy.set(tabId, {
676 ...current,
677 consumers,
678 lastPictureInPictureFrame:
679 consumer === "picture-in-picture" ? null : current.lastPictureInPictureFrame,
680 });
675 }),681 }),
676 ] as const;682 ] as const;
677 }683 }
...@@ -2615,18 +2621,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2615,18 +2621,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2615 tabId,2621 tabId,
2616 webContentsId: wc.id,2622 webContentsId: wc.id,
2617 },2623 },
2618 () => image.toJPEG(RECORDING_JPEG_QUALITY).toString("base64"),2624 () => image.toJPEG(RECORDING_JPEG_QUALITY),
2619 );2625 );
2626 const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
2627 if (frameSession?.scope !== captureSession.scope) return;
2628 const recording = frameSession.consumers.has("recording");
2629 const pictureInPicture =
2630 frameSession.consumers.has("picture-in-picture") &&
2631 frameSession.lastPictureInPictureFrame?.equals(encoded) !== true;
2632 if (!recording && !pictureInPicture) return;
2620 const receivedAt = yield* currentIso;2633 const receivedAt = yield* currentIso;
2621 const frame: DesktopPreviewRecordingFrame = {2634 const frame: DesktopPreviewRecordingFrame = {
2622 tabId,2635 tabId,
2623 data: encoded,2636 data: encoded.toString("base64"),
2624 width: size.width,2637 width: size.width,
2625 height: size.height,2638 height: size.height,
2626 receivedAt,2639 receivedAt,
2627 };2640 };
2628 const deliveries: Array<Effect.Effect<void>> = [];2641 const deliveries: Array<Effect.Effect<void>> = [];
2629 if (currentCaptureSession.consumers.has("recording")) {2642 if (recording) {
2630 const listeners = yield* Ref.get(recordingFrameListenersRef);2643 const listeners = yield* Ref.get(recordingFrameListenersRef);
2631 deliveries.push(2644 deliveries.push(
2632 Effect.forEach(2645 Effect.forEach(
...@@ -2636,7 +2649,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2636,7 +2649,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2636 ),2649 ),
2637 );2650 );
2638 }2651 }
2639 if (currentCaptureSession.consumers.has("picture-in-picture")) {2652 if (pictureInPicture) {
2640 const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(2653 const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
2641 tabId,2654 tabId,
2642 )?.window;2655 )?.window;
...@@ -2686,6 +2699,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2686,6 +2699,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2686 );2699 );
2687 },2700 },
2688 );2701 );
2702 yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => {
2703 if (sessions.get(tabId) !== frameSession) return sessions;
2704 return replaceMap(sessions, (copy) => {
2705 copy.set(tabId, {
2706 ...frameSession,
2707 lastPictureInPictureFrame: encoded,
2708 });
2709 });
2710 });
2689 }).pipe(2711 }).pipe(
2690 Effect.catch((error) =>2712 Effect.catch((error) =>
2691 Effect.logWarning("Picture-in-picture frame delivery failed.", {2713 Effect.logWarning("Picture-in-picture frame delivery failed.", {
...@@ -2753,6 +2775,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2753,6 +2775,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2753 copy.set(tabId, {2775 copy.set(tabId, {
2754 scope,2776 scope,
2755 consumers: new Set([consumer]),2777 consumers: new Set([consumer]),
2778 lastPictureInPictureFrame: null,
2756 });2779 });
2757 }),2780 }),
2758 ] as const;2781 ] as const;
...@@ -2907,6 +2930,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2907,6 +2930,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2907 ),2930 ),
2908 );2931 );
2909 };2932 };
2933 const onDidFinishLoad = () => {
2934 runFork(
2935 SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => {
2936 const current = sessions.get(tabId);
2937 if (!current?.consumers.has("picture-in-picture")) return sessions;
2938 return replaceMap(sessions, (copy) => {
2939 copy.set(tabId, { ...current, lastPictureInPictureFrame: null });
2940 });
2941 }),
2942 );
2943 };
2944 const pipWebContents = pictureInPictureWindow.webContents;
2910 yield* attempt(2945 yield* attempt(
2911 {2946 {
2912 operation: "pictureInPicture.configure",2947 operation: "pictureInPicture.configure",
...@@ -2927,6 +2962,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2927,6 +2962,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2927 skipTransformProcessType: true,2962 skipTransformProcessType: true,
2928 });2963 });
2929 }2964 }
2965 pipWebContents.on("did-finish-load", onDidFinishLoad);
2930 },2966 },
2931 ).pipe(2967 ).pipe(
2932 Effect.onError(() =>2968 Effect.onError(() =>
...@@ -2941,6 +2977,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2941,6 +2977,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2941 ),2977 ),
2942 ),2978 ),
2943 );2979 );
2980 yield* Scope.addFinalizer(
2981 initializationScope,
2982 Effect.sync(() => {
2983 pipWebContents.off("did-finish-load", onDidFinishLoad);
2984 }).pipe(Effect.ignore),
2985 );
2944 yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) =>2986 yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) =>
2945 replaceMap(sessions, (copy) => {2987 replaceMap(sessions, (copy) => {
2946 copy.set(tabId, session);2988 copy.set(tabId, session);
apps/web/src/previewStateStore.test.ts+33
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 rememberPreviewUrl,20 rememberPreviewUrl,
21 removePreviewThread,21 removePreviewThread,
22 resetPreviewStateForTests,22 resetPreviewStateForTests,
23 subscribeThreadPreviewState,
23 setActivePreviewTab,24 setActivePreviewTab,
24 updatePreviewServerSnapshot,25 updatePreviewServerSnapshot,
25} from "./previewStateStore";26} from "./previewStateStore";
...@@ -331,6 +332,38 @@ describe("previewStateStore (single-tab)", () => {...@@ -331,6 +332,38 @@ describe("previewStateStore (single-tab)", () => {
331 expect(state.snapshot?.canGoBack).toBe(false);332 expect(state.snapshot?.canGoBack).toBe(false);
332 });333 });
333334
335 it("does not publish duplicate desktop browser state", () => {
336 const snapshot = makeSnapshot();
337 applyPreviewServerSnapshot(ref, snapshot);
338 const overlay = {
339 hasWebContents: true,
340 canGoBack: true,
341 canGoForward: false,
342 loading: false,
343 zoomFactor: 1,
344 pictureInPicture: false,
345 colorScheme: "system" as const,
346 audioMuted: false,
347 audible: false,
348 controller: "none" as const,
349 favicon: {
350 dataUrl: "data:image/png;base64,AA==",
351 pageUrl: "https://example.com",
352 capturedAt: 1,
353 },
354 };
355 let updateCount = 0;
356 const unsubscribe = subscribeThreadPreviewState(ref, () => {
357 updateCount += 1;
358 });
359
360 applyPreviewDesktopState(ref, snapshot.tabId, overlay);
361 applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, favicon: { ...overlay.favicon } });
362 unsubscribe();
363
364 expect(updateCount).toBe(1);
365 });
366
334 it("retains multiple tabs and switches active desktop state", () => {367 it("retains multiple tabs and switches active desktop state", () => {
335 const first = makeSnapshot();368 const first = makeSnapshot();
336 const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" };369 const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" };
apps/web/src/previewStateStore.ts+27
...@@ -368,12 +368,39 @@ export function reconcilePreviewServerSessions(...@@ -368,12 +368,39 @@ export function reconcilePreviewServerSessions(
368 });368 });
369}369}
370370
371function isPreviewStateEqual(
372 previous: DesktopPreviewOverlay | null,
373 next: DesktopPreviewOverlay | null,
374) {
375 return (
376 previous === next ||
377 (previous !== null &&
378 next !== null &&
379 previous.hasWebContents === next.hasWebContents &&
380 previous.canGoBack === next.canGoBack &&
381 previous.canGoForward === next.canGoForward &&
382 previous.loading === next.loading &&
383 previous.zoomFactor === next.zoomFactor &&
384 previous.pictureInPicture === next.pictureInPicture &&
385 previous.colorScheme === next.colorScheme &&
386 previous.audioMuted === next.audioMuted &&
387 previous.audible === next.audible &&
388 previous.controller === next.controller &&
389 previous.favicon?.dataUrl === next.favicon?.dataUrl &&
390 previous.favicon?.pageUrl === next.favicon?.pageUrl &&
391 previous.favicon?.capturedAt === next.favicon?.capturedAt)
392 );
393}
394
371export function applyPreviewDesktopState(395export function applyPreviewDesktopState(
372 ref: ScopedThreadRef,396 ref: ScopedThreadRef,
373 tabId: string,397 tabId: string,
374 overlay: DesktopPreviewOverlay | null,398 overlay: DesktopPreviewOverlay | null,
375): void {399): void {
376 updateThreadPreviewState(ref, (current) => {400 updateThreadPreviewState(ref, (current) => {
401 if (isPreviewStateEqual(current.desktopByTabId[tabId] ?? null, overlay)) {
402 return current;
403 }
377 const desktopByTabId = { ...current.desktopByTabId };404 const desktopByTabId = { ...current.desktopByTabId };
378 if (overlay) desktopByTabId[tabId] = overlay;405 if (overlay) desktopByTabId[tabId] = overlay;
379 else delete desktopByTabId[tabId];406 else delete desktopByTabId[tabId];