From 512d50460735b4bc513b6bdba5d665d055c3031f Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 24 Jul 2026 16:04:18 +0800 Subject: [PATCH] fix(webview): serialize inline webview visibility transitions Native WKWebView show/hide mutations could race when visibility flipped quickly, leaving an inline webview shown offscreen or hidden while active. - Serialize per-owner native mutations through a transition queue and use a generation counter so a newer visibility intent invalidates queued work before it reaches Tauri, while an in-flight mutation is allowed to finish. - Fold the show call into updatePosition({ force, show }) and gate updatePosition on isVisible so offscreen owners never reposition. Adds unit tests for native-visibility sequencing and layout gating. --- .../BrowserCore.webviewRetention.test.ts | 170 ++++++++++++++++++ src/engines/BrowserCore/index.tsx | 80 +++++++-- .../useInlineWebviewNativeVisibility.test.ts | 157 ++++++++++++++++ .../__tests__/useWebviewLayout.test.ts | 163 +++++++++++++++++ .../useInlineWebview/useInlineWebview.ts | 1 + .../useInlineWebviewNativeVisibility.ts | 34 ++-- .../useInlineWebview/useWebviewLayout.ts | 42 +++-- 7 files changed, 611 insertions(+), 36 deletions(-) create mode 100644 src/engines/BrowserCore/BrowserCore.webviewRetention.test.ts create mode 100644 src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewNativeVisibility.test.ts create mode 100644 src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts diff --git a/src/engines/BrowserCore/BrowserCore.webviewRetention.test.ts b/src/engines/BrowserCore/BrowserCore.webviewRetention.test.ts new file mode 100644 index 000000000..d828c189c --- /dev/null +++ b/src/engines/BrowserCore/BrowserCore.webviewRetention.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { UseBrowserStateReturn } from "./hooks/useBrowserState"; +import { + BrowserCore, + MAX_RETAINED_BROWSER_WEBVIEWS, + selectRetainedBrowserSessionIds, +} from "./index"; +import type { BrowserSession } from "./types"; + +vi.mock("jotai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAtomValue: () => false, + }; +}); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("./BrowserSessionWebview", () => ({ + default: ({ + session, + isActive, + }: { + session: BrowserSession; + isActive: boolean; + }) => + createElement("div", { + "data-browser-webview-session": session.id, + "data-active": String(isActive), + }), +})); + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +function session( + id: string, + url = `https://${id}.example.com` +): BrowserSession { + return { + id, + title: id, + url, + history: url ? [url] : [], + historyIndex: url ? 0 : -1, + historyEntries: [], + isLoading: false, + error: null, + incognito: false, + }; +} + +function browserState( + sessions: BrowserSession[], + activeSessionId: string +): UseBrowserStateReturn { + return { + sessions, + activeSessionId, + activeSession: sessions.find((item) => item.id === activeSessionId), + addSession: vi.fn(), + closeSession: vi.fn(), + setActiveSession: vi.fn(), + updateSession: vi.fn(), + }; +} + +describe("BrowserCore retained native WebViews", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + Reflect.deleteProperty( + window as unknown as Record, + "__TAURI_INTERNALS__" + ); + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + vi.restoreAllMocks(); + }); + + function renderWith( + sessions: BrowserSession[], + activeSessionId: string + ): void { + act(() => { + root.render( + createElement(BrowserCore, { + browserState: browserState(sessions, activeSessionId), + }) + ); + }); + } + + function mountedSessionIds(): string[] { + return Array.from( + container.querySelectorAll("[data-browser-webview-session]") + ).map((element) => element.getAttribute("data-browser-webview-session")!); + } + + it("keeps only the active and most recently active native WebViews mounted", () => { + const sessions = [session("a"), session("b"), session("c")]; + + renderWith(sessions, "a"); + expect(mountedSessionIds()).toEqual(["a"]); + + renderWith(sessions, "b"); + expect(mountedSessionIds()).toEqual(["a", "b"]); + + renderWith(sessions, "c"); + expect(mountedSessionIds()).toEqual(["b", "c"]); + + renderWith(sessions, "a"); + expect(mountedSessionIds()).toEqual(["a", "c"]); + expect(mountedSessionIds()).toHaveLength(MAX_RETAINED_BROWSER_WEBVIEWS); + }); + + it("does not mount restored background sessions or blank tabs eagerly", () => { + const sessions = [session("a"), session("blank", ""), session("c")]; + + renderWith(sessions, "blank"); + expect(mountedSessionIds()).toEqual([]); + + renderWith(sessions, "c"); + expect(mountedSessionIds()).toEqual(["c"]); + }); + + it("keeps the native mount count bounded across repeated session switches", () => { + const sessions = [session("a"), session("b"), session("c"), session("d")]; + + for (let index = 0; index < 50; index += 1) { + const activeSessionId = sessions[index % sessions.length].id; + renderWith(sessions, activeSessionId); + + expect(mountedSessionIds()).toContain(activeSessionId); + expect(mountedSessionIds().length).toBeLessThanOrEqual( + MAX_RETAINED_BROWSER_WEBVIEWS + ); + } + }); +}); + +describe("selectRetainedBrowserSessionIds", () => { + it("drops deleted and non-navigable sessions while preserving recency", () => { + expect( + selectRetainedBrowserSessionIds( + ["deleted", "a", "blank"], + [session("a"), session("blank", ""), session("b")], + "b" + ) + ).toEqual(["a", "b"]); + }); +}); diff --git a/src/engines/BrowserCore/index.tsx b/src/engines/BrowserCore/index.tsx index 4c1bc3b3f..985e6edcc 100644 --- a/src/engines/BrowserCore/index.tsx +++ b/src/engines/BrowserCore/index.tsx @@ -36,12 +36,14 @@ import BrowserSessionWebview from "./BrowserSessionWebview"; import type { UseBrowserStateReturn } from "./hooks/useBrowserState"; import "./index.scss"; import { BROWSER_WEBVIEW_FRAME_ANCHOR_ATTRIBUTE } from "./nativeFrameAnchor"; +import type { BrowserSession } from "./types"; const log = createLogger("BrowserCore"); const ABOUT_BLANK_URL = "about:blank"; const SHOW_WEBVIEW_FRAME_ANCHOR = false; const EMBEDDED_BROWSER_WARNING_DELAY_MS = 3000; +export const MAX_RETAINED_BROWSER_WEBVIEWS = 2; const EMBEDDED_BROWSER_SENSITIVE_HOSTS = new Set([ "github.com", "www.github.com", @@ -66,6 +68,37 @@ function shouldShowEmbeddedBrowserFallback(url?: string): boolean { } } +export function selectRetainedBrowserSessionIds( + previousIds: readonly string[], + sessions: readonly BrowserSession[], + activeSessionId: string, + maxRetained = MAX_RETAINED_BROWSER_WEBVIEWS +): string[] { + if (maxRetained <= 0) return []; + + const navigableIds = new Set( + sessions + .filter((session) => !isBlankBrowserUrl(session.url)) + .map((session) => session.id) + ); + const next = previousIds.filter( + (sessionId) => navigableIds.has(sessionId) && sessionId !== activeSessionId + ); + + if (navigableIds.has(activeSessionId)) { + next.push(activeSessionId); + } + + return next.slice(-maxRetained); +} + +function sameIds(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((sessionId, index) => sessionId === right[index]) + ); +} + // ============================================ // Props // ============================================ @@ -112,6 +145,29 @@ export const BrowserCore: React.FC = ({ }) => { const { t } = useTranslation(); const { sessions, activeSessionId, updateSession, addSession } = browserState; + const [retainedWebviewSessionIds, setRetainedWebviewSessionIds] = + React.useState([]); + const nextRetainedWebviewSessionIds = useMemo( + () => + selectRetainedBrowserSessionIds( + retainedWebviewSessionIds, + sessions, + activeSessionId + ), + [activeSessionId, retainedWebviewSessionIds, sessions] + ); + const retainedWebviewSessionIdSet = useMemo( + () => new Set(nextRetainedWebviewSessionIds), + [nextRetainedWebviewSessionIds] + ); + + React.useLayoutEffect(() => { + setRetainedWebviewSessionIds((previousIds) => + sameIds(previousIds, nextRetainedWebviewSessionIds) + ? previousIds + : nextRetainedWebviewSessionIds + ); + }, [nextRetainedWebviewSessionIds]); // Check if webviews should be blocked by overlays or station ownership. const isWebviewBlocked = useAtomValue(webviewBlockedAtom); @@ -282,17 +338,19 @@ export const BrowserCore: React.FC = ({ {/* Only the owning instance renders BrowserSessionWebview. */} {manageWebviews && - sessions.map((session) => ( - - ))} + sessions + .filter((session) => retainedWebviewSessionIdSet.has(session.id)) + .map((session) => ( + + ))} {/* Desktop-only notice */} {!isWebviewAvailable && ( diff --git a/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewNativeVisibility.test.ts b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewNativeVisibility.test.ts new file mode 100644 index 000000000..df6ed6446 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewNativeVisibility.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useInlineWebviewNativeVisibility } from "../useInlineWebviewNativeVisibility"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +const labelRef = { current: "browser-session-test" }; + +function deferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function VisibilityHarness({ + isVisible, + updatePosition, +}: { + isVisible: boolean; + updatePosition: (options?: { + force?: boolean; + show?: boolean; + }) => Promise; +}) { + useInlineWebviewNativeVisibility({ + isWebviewCreated: true, + isVisible, + isWebviewAvailable: true, + labelRef, + updatePosition, + log: vi.fn(), + }); + return null; +} + +describe("useInlineWebviewNativeVisibility", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + invokeMock.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("serializes native transitions and applies the latest visibility intent", async () => { + const hiddenTransition = deferred(); + const updatePosition = vi.fn().mockResolvedValue(undefined); + invokeMock.mockReturnValueOnce(hiddenTransition.promise); + + await act(async () => { + root.render( + createElement(VisibilityHarness, { + isVisible: false, + updatePosition, + }) + ); + await Promise.resolve(); + }); + + expect(invokeMock).toHaveBeenCalledWith("update_inline_webview_position", { + label: "browser-session-test", + x: -10000, + y: -10000, + width: 1, + height: 1, + }); + + await act(async () => { + root.render( + createElement(VisibilityHarness, { + isVisible: true, + updatePosition, + }) + ); + await Promise.resolve(); + }); + expect(updatePosition).not.toHaveBeenCalled(); + + await act(async () => { + hiddenTransition.resolve(); + await hiddenTransition.promise; + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(updatePosition).toHaveBeenCalledTimes(1); + expect(updatePosition).toHaveBeenCalledWith({ force: true, show: true }); + }); + + it("skips a queued show transition that a newer hide supersedes", async () => { + const firstHide = deferred(); + const updatePosition = vi.fn().mockResolvedValue(undefined); + invokeMock + .mockReturnValueOnce(firstHide.promise) + .mockResolvedValueOnce(undefined); + + await act(async () => { + root.render( + createElement(VisibilityHarness, { + isVisible: false, + updatePosition, + }) + ); + await Promise.resolve(); + }); + + act(() => { + root.render( + createElement(VisibilityHarness, { + isVisible: true, + updatePosition, + }) + ); + root.render( + createElement(VisibilityHarness, { + isVisible: false, + updatePosition, + }) + ); + }); + + await act(async () => { + firstHide.resolve(); + await firstHide.promise; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(updatePosition).not.toHaveBeenCalled(); + expect(invokeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts b/src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts new file mode 100644 index 000000000..b96ea08e3 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { UseWebviewLayoutReturn } from "../useWebviewLayout"; +import { useWebviewLayout } from "../useWebviewLayout"; +import { WEBVIEW_LAYOUT_CHANGED_EVENT } from "../webviewLayoutEvents"; + +const invokeMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +const labelRef = { current: "browser-session-layout-test" }; +const layoutContainerRef = { current: null as HTMLDivElement | null }; + +class ResizeObserverMock { + static instances: ResizeObserverMock[] = []; + readonly disconnect = vi.fn(); + readonly observe = vi.fn(); + + constructor(readonly callback: ResizeObserverCallback) { + ResizeObserverMock.instances.push(this); + } + + unobserve(): void {} +} + +let latestLayout: UseWebviewLayoutReturn | null = null; + +function LayoutHarness({ isVisible }: { isVisible: boolean }) { + const layout = useWebviewLayout({ + containerRef: layoutContainerRef, + isWebviewCreated: true, + isWebviewAvailable: true, + isVisible, + labelRef, + log: vi.fn(), + }); + useEffect(() => { + latestLayout = layout; + return () => { + latestLayout = null; + }; + }, [layout]); + return null; +} + +describe("useWebviewLayout visibility lifecycle", () => { + let container: HTMLDivElement; + let root: Root; + let rectSpy: ReturnType; + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + invokeMock.mockClear(); + ResizeObserverMock.instances = []; + globalThis.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + layoutContainerRef.current = document.createElement("div"); + rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + x: 10, + y: 20, + left: 10, + top: 20, + right: 310, + bottom: 220, + width: 300, + height: 200, + toJSON: () => ({}), + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + rectSpy.mockRestore(); + latestLayout = null; + layoutContainerRef.current = null; + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("disconnects observers and ignores layout work while hidden", async () => { + act(() => { + root.render(createElement(LayoutHarness, { isVisible: true })); + }); + expect(ResizeObserverMock.instances).toHaveLength(1); + + await act(async () => { + await latestLayout!.updatePosition({ force: true }); + }); + expect(invokeMock).toHaveBeenLastCalledWith( + "update_inline_webview_position", + expect.objectContaining({ + label: "browser-session-layout-test", + x: 10, + y: 20, + width: 300, + height: 200, + }) + ); + + act(() => { + root.render(createElement(LayoutHarness, { isVisible: false })); + }); + expect(ResizeObserverMock.instances[0].disconnect).toHaveBeenCalledTimes(1); + + invokeMock.mockClear(); + await act(async () => { + await latestLayout!.updatePosition({ force: true }); + window.dispatchEvent(new Event(WEBVIEW_LAYOUT_CHANGED_EVENT)); + await Promise.resolve(); + }); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("uses the atomic reposition-and-show command when becoming visible", async () => { + act(() => { + root.render(createElement(LayoutHarness, { isVisible: true })); + }); + + await act(async () => { + await latestLayout!.updatePosition({ force: true, show: true }); + }); + + expect(invokeMock).toHaveBeenCalledWith( + "reposition_and_show_webview", + expect.objectContaining({ + label: "browser-session-layout-test", + x: 10, + y: 20, + width: 300, + height: 200, + }) + ); + }); + + it("cleans up every observer across repeated visible/hidden cycles", () => { + for (let index = 0; index < 20; index += 1) { + act(() => { + root.render(createElement(LayoutHarness, { isVisible: true })); + }); + act(() => { + root.render(createElement(LayoutHarness, { isVisible: false })); + }); + } + + expect(ResizeObserverMock.instances).toHaveLength(20); + for (const observer of ResizeObserverMock.instances) { + expect(observer.disconnect).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/hooks/platform/useInlineWebview/useInlineWebview.ts b/src/hooks/platform/useInlineWebview/useInlineWebview.ts index 0ebfea3af..dba41941e 100644 --- a/src/hooks/platform/useInlineWebview/useInlineWebview.ts +++ b/src/hooks/platform/useInlineWebview/useInlineWebview.ts @@ -77,6 +77,7 @@ export function useInlineWebview( containerRef, isWebviewCreated, isWebviewAvailable, + isVisible, labelRef, log, }); diff --git a/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts b/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts index e9bb5adfd..32befd22c 100644 --- a/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts +++ b/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts @@ -1,12 +1,15 @@ import { invoke } from "@tauri-apps/api/core"; -import { type MutableRefObject, useEffect } from "react"; +import { type MutableRefObject, useEffect, useRef } from "react"; export interface UseInlineWebviewNativeVisibilityParams { isWebviewCreated: boolean; isVisible: boolean; isWebviewAvailable: boolean; labelRef: MutableRefObject; - updatePosition: (options?: { force?: boolean }) => Promise; + updatePosition: (options?: { + force?: boolean; + show?: boolean; + }) => Promise; log: (...args: unknown[]) => void; } @@ -21,22 +24,21 @@ export function useInlineWebviewNativeVisibility( updatePosition, log, } = params; + const transitionGenerationRef = useRef(0); + const transitionQueueRef = useRef>(Promise.resolve()); useEffect(() => { if (!isWebviewCreated || !isWebviewAvailable) return; - let cancelled = false; + const generation = ++transitionGenerationRef.current; const handleVisibility = async () => { + if (generation !== transitionGenerationRef.current) return; + try { if (isVisible) { log("Showing WebView (isVisible=true)"); - await updatePosition({ force: true }); - if (cancelled) return; - await invoke("set_inline_webview_visibility", { - label: labelRef.current, - visible: true, - }); + await updatePosition({ force: true, show: true }); } else { log("Staging WebView offscreen (isVisible=false, but still mounted)"); await invoke("update_inline_webview_position", { @@ -48,16 +50,24 @@ export function useInlineWebviewNativeVisibility( }); } } catch (err) { - if (!cancelled) { + if (generation === transitionGenerationRef.current) { log("Visibility change failed:", err); } } }; - void handleVisibility(); + // Native WKWebView mutations are serialized per React owner. A newer + // visibility intent invalidates queued work before it reaches Tauri, while + // an already-running mutation is allowed to finish before the latest + // transition applies the final state. + transitionQueueRef.current = transitionQueueRef.current + .catch(() => undefined) + .then(handleVisibility); return () => { - cancelled = true; + if (transitionGenerationRef.current === generation) { + transitionGenerationRef.current += 1; + } }; }, [ isWebviewCreated, diff --git a/src/hooks/platform/useInlineWebview/useWebviewLayout.ts b/src/hooks/platform/useInlineWebview/useWebviewLayout.ts index 25f299d88..7ceee8496 100644 --- a/src/hooks/platform/useInlineWebview/useWebviewLayout.ts +++ b/src/hooks/platform/useInlineWebview/useWebviewLayout.ts @@ -22,20 +22,30 @@ export interface UseWebviewLayoutParams { containerRef: RefObject; isWebviewCreated: boolean; isWebviewAvailable: boolean; + isVisible: boolean; labelRef: MutableRefObject; log: (...args: unknown[]) => void; } export interface UseWebviewLayoutReturn { getContainerRect: () => DOMRect | null; - updatePosition: (options?: { force?: boolean }) => Promise; + updatePosition: (options?: { + force?: boolean; + show?: boolean; + }) => Promise; } export function useWebviewLayout( params: UseWebviewLayoutParams ): UseWebviewLayoutReturn { - const { containerRef, isWebviewCreated, isWebviewAvailable, labelRef, log } = - params; + const { + containerRef, + isWebviewCreated, + isWebviewAvailable, + isVisible, + labelRef, + log, + } = params; const resizeObserverRef = useRef(null); const scrollListenerRef = useRef<(() => void) | null>(null); @@ -52,8 +62,8 @@ export function useWebviewLayout( }, [containerRef]); const updatePosition = useCallback( - async (options?: { force?: boolean }) => { - if (!isWebviewCreated || !containerRef.current) return; + async (options?: { force?: boolean; show?: boolean }) => { + if (!isWebviewCreated || !isVisible || !containerRef.current) return; const rect = getContainerRect(); if (!rect) return; @@ -86,16 +96,21 @@ export function useWebviewLayout( lastResizeRect.current = nativeFrame; try { - await invoke("update_inline_webview_position", { - label: labelRef.current, - ...nativeFrame, - }); + await invoke( + options?.show + ? "reposition_and_show_webview" + : "update_inline_webview_position", + { + label: labelRef.current, + ...nativeFrame, + } + ); log("Position updated:", { rect, nativeFrame }); } catch (err) { log("Failed to update position:", err); } }, - [isWebviewCreated, containerRef, getContainerRect, labelRef, log] + [isWebviewCreated, isVisible, containerRef, getContainerRect, labelRef, log] ); const debouncedUpdatePosition = useDebouncedCallback(() => { @@ -103,7 +118,7 @@ export function useWebviewLayout( }, DEBOUNCE_DELAYS.FRAME); useEffect(() => { - if (!containerRef.current || !isWebviewAvailable) return; + if (!containerRef.current || !isWebviewAvailable || !isVisible) return; resizeObserverRef.current = new ResizeObserver(() => { debouncedUpdatePosition(); @@ -115,10 +130,10 @@ export function useWebviewLayout( resizeObserverRef.current?.disconnect(); debouncedUpdatePosition.cancel(); }; - }, [containerRef, isWebviewAvailable, debouncedUpdatePosition]); + }, [containerRef, isWebviewAvailable, isVisible, debouncedUpdatePosition]); useEffect(() => { - if (!isWebviewCreated || !isWebviewAvailable) return; + if (!isWebviewCreated || !isWebviewAvailable || !isVisible) return; const scaleUpdateTimers = new Set(); @@ -191,6 +206,7 @@ export function useWebviewLayout( containerRef, isWebviewCreated, isWebviewAvailable, + isVisible, debouncedUpdatePosition, updatePosition, ]);