diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index caa2dd3e2a70..e0a39fbd5306 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,14 +6,19 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; -import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { + type BrowserSurfaceContentPresentation, + type BrowserSurfaceRect, + resolveBrowserSurfacePanelRect, + useBrowserSurfaceStore, +} from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; import { browserViewportSettingKey, @@ -136,15 +141,14 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); - const latestUrlRef = useRef(initialUrl); - - useEffect(() => { - latestUrlRef.current = initialUrl; - }, [initialUrl]); + const remountCrashedWebview = useEffectEvent(() => { + setRecoverySrc(initialUrl ?? initialSrc); + setWebviewGeneration((generation) => generation + 1); + }); - const setWebviewRef = useCallback((node: HTMLElement | null) => { + const setWebviewRef = (node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; - }, []); + }; useEffect(() => { const webview = webviewRef.current; @@ -176,10 +180,7 @@ export function HostedBrowserWebview(props: { crashRecoveryRef.current = recovery.state; recoveryTimeout = setTimeout(() => { recoveryTimeout = null; - if (!disposed) { - setRecoverySrc(latestUrlRef.current ?? initialSrc); - setWebviewGeneration((generation) => generation + 1); - } + if (!disposed) remountCrashedWebview(); }, recovery.delayMs); }; // A click inside the guest only reaches this document as a webview focus @@ -203,7 +204,13 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("render-process-gone", recoverGuest); webview.removeEventListener("focus", dismissHostPopups); }; - }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); + }, [ + clientSettingsHydrated, + config, + runtimeTabId, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A crash recovery remounts the webview under a new key, so the listeners attach to the new element. + webviewGeneration, + ]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -214,25 +221,10 @@ export function HostedBrowserWebview(props: { viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight; const lockedAspectRatio = aspectRatioLocked && viewportAspectRatio !== null ? viewportAspectRatio : null; - const handleAspectRatioChange = useCallback((aspectRatio: number | null) => { + const handleAspectRatioChange = (aspectRatio: number | null) => { setAspectRatioLocked(aspectRatio !== null); - }, []); - const hiddenContentSize = presentation.content - ? { - width: presentation.content.width / presentation.content.scale, - height: presentation.content.height / presentation.content.scale, - } - : null; - const hiddenSize = - viewport._tag !== "fill" - ? { - width: viewport.width * normalizedZoomFactor, - height: viewport.height * normalizedZoomFactor, - } - : { - width: hiddenContentSize?.width ?? lastRect?.width ?? 1280, - height: hiddenContentSize?.height ?? lastRect?.height ?? 800, - }; + }; + const hiddenSize = useHiddenSize(viewport, normalizedZoomFactor, presentation.content, lastRect); const containerSize = active && lastRect ? lastRect : hiddenSize; const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent; const { @@ -263,7 +255,7 @@ export function HostedBrowserWebview(props: { ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor) : viewportLayout; - const syncContentPresentation = useCallback(() => { + const syncContentPresentation = () => { const wrapper = wrapperRef.current; if (!wrapper) return; useBrowserSurfaceStore.getState().presentContent(runtimeTabId, { @@ -275,7 +267,7 @@ export function HostedBrowserWebview(props: { scrollLeft: wrapper.scrollLeft, scrollTop: wrapper.scrollTop, }); - }, [layout, runtimeTabId]); + }; useEffect(() => { const frameId = window.requestAnimationFrame(syncContentPresentation); @@ -286,7 +278,13 @@ export function HostedBrowserWebview(props: { const wrapper = wrapperRef.current; if (!wrapper) return; wrapper.scrollTo({ left: 0, top: 0 }); - }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); + }, [ + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new tab or viewport size starts the canvas scrolled to its origin. + runtimeTabId, + viewport._tag, + viewportHeight, + viewportWidth, + ]); if (!clientSettingsHydrated || !config) return null; @@ -392,3 +390,20 @@ export function HostedBrowserWebview(props: { ); } + +// The size feeds useBrowserViewportResize, whose layout keys the content sync effect. +function useHiddenSize( + viewport: PreviewViewportSetting, + zoomFactor: number, + content: BrowserSurfaceContentPresentation | null, + lastRect: BrowserSurfaceRect | null, +) { + "use memo"; + if (viewport._tag !== "fill") { + return { width: viewport.width * zoomFactor, height: viewport.height * zoomFactor }; + } + return { + width: content ? content.width / content.scale : (lastRect?.width ?? 1280), + height: content ? content.height / content.scale : (lastRect?.height ?? 800), + }; +}