authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-21 12:58:42+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 02:58:42-07:00
logbe7d35aaeb49a04483ec5e0d2284e8b5b70a3b6e
treef962d24ad49ed7e5defad4c63d584ee77b482a26
parent549201fcfd3baa6cf92f09e5d868c536c80d2d55
signature Signed by PGP key B5690EEEBB952194

perf(web): stop preview loading rerenders (#7561)


6 files changed, 135 insertions(+), 63 deletions(-)

apps/web/src/components/preview/PreviewChromeRow.test.tsx-1
...@@ -9,7 +9,6 @@ describe("PreviewChromeRow", () => {...@@ -9,7 +9,6 @@ describe("PreviewChromeRow", () => {
9 <PreviewChromeRow9 <PreviewChromeRow
10 url="https://example.com/dashboard?mode=edit&tab=1#notes"10 url="https://example.com/dashboard?mode=edit&tab=1#notes"
11 loading={false}11 loading={false}
12 loadProgress={0}
13 canGoBack={false}12 canGoBack={false}
14 canGoForward={false}13 canGoForward={false}
15 refreshDisabled={false}14 refreshDisabled={false}
apps/web/src/components/preview/PreviewChromeRow.tsx+6-12
...@@ -24,7 +24,6 @@ import { cn } from "~/lib/utils";...@@ -24,7 +24,6 @@ import { cn } from "~/lib/utils";
24interface Props {24interface Props {
25 url: string;25 url: string;
26 loading: boolean;26 loading: boolean;
27 loadProgress: number;
28 canGoBack: boolean;27 canGoBack: boolean;
29 canGoForward: boolean;28 canGoForward: boolean;
30 refreshDisabled: boolean;29 refreshDisabled: boolean;
...@@ -65,7 +64,6 @@ const NOOP = () => {};...@@ -65,7 +64,6 @@ const NOOP = () => {};
65export function PreviewChromeRow({64export function PreviewChromeRow({
66 url,65 url,
67 loading,66 loading,
68 loadProgress,
69 canGoBack,67 canGoBack,
70 canGoForward,68 canGoForward,
71 refreshDisabled,69 refreshDisabled,
...@@ -308,16 +306,12 @@ export function PreviewChromeRow({...@@ -308,16 +306,12 @@ export function PreviewChromeRow({
308 ) : null}306 ) : null}
309 {trailingActions}307 {trailingActions}
310 </form>308 </form>
311 {loadProgress > 0 ? (309 <div
312 <div310 aria-hidden
313 aria-hidden311 data-loading={loading}
314 className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-r-full bg-primary transition-all duration-150 ease-out"312 className="preview-loading-progress pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 w-full origin-left rounded-r-full bg-primary"
315 style={{313 style={{ boxShadow: "0 0 6px 1px var(--color-ring)" }}
316 width: `${loadProgress}%`,314 />
317 boxShadow: "0 0 6px 1px var(--color-ring)",
318 }}
319 />
320 ) : null}
321 </div>315 </div>
322 );316 );
323}317}
apps/web/src/components/preview/PreviewView.test.tsx+94-2
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 FILL_PREVIEW_VIEWPORT,5 FILL_PREVIEW_VIEWPORT,
6 ThreadId,6 ThreadId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { act, Profiler } from "react";
8import { renderToStaticMarkup } from "react-dom/server";9import { renderToStaticMarkup } from "react-dom/server";
9import { beforeEach, describe, expect, it, vi } from "vite-plus/test";10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1011
...@@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({...@@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({
30 toggleAnnotation: null as (() => void) | null,31 toggleAnnotation: null as (() => void) | null,
31 pictureInPicture: false,32 pictureInPicture: false,
32 showEmptyState: false,33 showEmptyState: false,
34 loading: false,
33 recordVisitForThread: vi.fn(),35 recordVisitForThread: vi.fn(),
34}));36}));
3537
...@@ -103,7 +105,7 @@ vi.mock("~/previewStateStore", () => ({...@@ -103,7 +105,7 @@ vi.mock("~/previewStateStore", () => ({
103 hasWebContents: true,105 hasWebContents: true,
104 canGoBack: false,106 canGoBack: false,
105 canGoForward: false,107 canGoForward: false,
106 loading: false,108 loading: mocks.loading,
107 zoomFactor: 1,109 zoomFactor: 1,
108 pictureInPicture: mocks.pictureInPicture,110 pictureInPicture: mocks.pictureInPicture,
109 colorScheme: "system",111 colorScheme: "system",
...@@ -244,7 +246,6 @@ vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null }));...@@ -244,7 +246,6 @@ vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null }));
244vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null }));246vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null }));
245vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null }));247vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null }));
246vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null }));248vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null }));
247vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 }));
248vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));249vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));
249250
250import { PreviewView } from "./PreviewView";251import { PreviewView } from "./PreviewView";
...@@ -256,6 +257,68 @@ const TEST_THREAD_REF = {...@@ -256,6 +257,68 @@ const TEST_THREAD_REF = {
256} as const;257} as const;
257const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1");258const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1");
258259
260// ReactDOM needs a host, but this unit suite intentionally has no DOM dependency.
261class TestNode {
262 parentNode: TestNode | null = null;
263 childNodes: TestNode[] = [];
264 readonly nodeName: string;
265 readonly tagName: string;
266 readonly namespaceURI = "http://www.w3.org/1999/xhtml";
267 readonly style = {};
268
269 constructor(
270 name: string,
271 readonly ownerDocument: TestNode | null = null,
272 readonly nodeType = 1,
273 ) {
274 this.nodeName = name.toUpperCase();
275 this.tagName = this.nodeName;
276 }
277
278 set textContent(_value: string) {
279 this.childNodes = [];
280 }
281
282 appendChild(child: TestNode) {
283 child.parentNode = this;
284 this.childNodes.push(child);
285 return child;
286 }
287
288 removeChild(child: TestNode) {
289 this.childNodes.splice(this.childNodes.indexOf(child), 1);
290 child.parentNode = null;
291 return child;
292 }
293
294 createElement(name: string) {
295 return new TestNode(name, this);
296 }
297
298 addEventListener() {}
299 removeEventListener() {}
300 setAttribute() {}
301}
302
303function installTestDom() {
304 const document = new TestNode("#document", null, 9);
305 const window = {
306 document,
307 HTMLIFrameElement: TestNode,
308 setInterval: globalThis.setInterval,
309 clearInterval: globalThis.clearInterval,
310 setTimeout: globalThis.setTimeout,
311 clearTimeout: globalThis.clearTimeout,
312 addEventListener() {},
313 removeEventListener() {},
314 };
315 vi.stubGlobal("document", document);
316 vi.stubGlobal("window", window);
317 vi.stubGlobal("HTMLIFrameElement", window.HTMLIFrameElement);
318 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
319 return document;
320}
321
259describe("PreviewView navigation", () => {322describe("PreviewView navigation", () => {
260 beforeEach(() => {323 beforeEach(() => {
261 mocks.navigate.mockClear();324 mocks.navigate.mockClear();
...@@ -279,9 +342,38 @@ describe("PreviewView navigation", () => {...@@ -279,9 +342,38 @@ describe("PreviewView navigation", () => {
279 mocks.toggleAnnotation = null;342 mocks.toggleAnnotation = null;
280 mocks.pictureInPicture = false;343 mocks.pictureInPicture = false;
281 mocks.showEmptyState = false;344 mocks.showEmptyState = false;
345 mocks.loading = false;
282 mocks.recordVisitForThread.mockClear();346 mocks.recordVisitForThread.mockClear();
283 });347 });
284348
349 it("does not rerender while loading time passes", async () => {
350 vi.useFakeTimers();
351 mocks.loading = true;
352 const document = installTestDom();
353 const { createRoot } = await import("react-dom/client");
354 const root = createRoot(document.createElement("div") as unknown as Element);
355 const onRender = vi.fn();
356
357 try {
358 await act(() => {
359 root.render(
360 <Profiler id="preview" onRender={onRender}>
361 <PreviewView threadRef={TEST_THREAD_REF} tabId="tab-1" visible />
362 </Profiler>,
363 );
364 });
365 const initialRenderCount = onRender.mock.calls.length;
366
367 await act(() => vi.advanceTimersByTimeAsync(1_000));
368
369 expect(onRender).toHaveBeenCalledTimes(initialRenderCount);
370 } finally {
371 await act(() => root.unmount());
372 vi.useRealTimers();
373 vi.unstubAllGlobals();
374 }
375 });
376
285 it.each([377 it.each([
286 [378 [
287 "https://localhost:8000/dashboard?mode=test#top",379 "https://localhost:8000/dashboard?mode=test#top",
apps/web/src/components/preview/PreviewView.tsx-3
...@@ -50,7 +50,6 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";...@@ -50,7 +50,6 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";
50import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";50import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
51import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";51import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
52import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";52import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
53import { useLoadingProgress } from "./useLoadingProgress";
54import { usePreviewSession } from "./usePreviewSession";53import { usePreviewSession } from "./usePreviewSession";
55import { ZoomIndicator } from "./ZoomIndicator";54import { ZoomIndicator } from "./ZoomIndicator";
56import { AgentBrowserCursor } from "./AgentBrowserCursor";55import { AgentBrowserCursor } from "./AgentBrowserCursor";
...@@ -142,7 +141,6 @@ export function PreviewView({...@@ -142,7 +141,6 @@ export function PreviewView({
142 const isUnreachable = navStatus._tag === "LoadFailed";141 const isUnreachable = navStatus._tag === "LoadFailed";
143 const showEmptyState = shouldShowPreviewEmptyState(snapshot);142 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
144 const controller = desktopOverlay?.controller ?? "none";143 const controller = desktopOverlay?.controller ?? "none";
145 const loadProgress = useLoadingProgress(loading);
146 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;144 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
147 const browserDefaults = useBrowserDefaults();145 const browserDefaults = useBrowserDefaults();
148 const panelRect = useBrowserSurfaceStore((state) =>146 const panelRect = useBrowserSurfaceStore((state) =>
...@@ -661,7 +659,6 @@ export function PreviewView({...@@ -661,7 +659,6 @@ export function PreviewView({
661 <PreviewChromeRow659 <PreviewChromeRow
662 url={url}660 url={url}
663 loading={loading}661 loading={loading}
664 loadProgress={loadProgress}
665 canGoBack={canGoBack}662 canGoBack={canGoBack}
666 canGoForward={canGoForward}663 canGoForward={canGoForward}
667 refreshDisabled={refreshDisabled}664 refreshDisabled={refreshDisabled}
apps/web/src/components/preview/useLoadingProgress.ts deleted-45
...@@ -1,45 +0,0 @@
1import { useEffect, useRef, useState } from "react";
2
3const TICK_INTERVAL_MS = 120;
4const FADE_OUT_DELAY_MS = 220;
5const SEED_PERCENT = 4;
6const ASYMPTOTE_PERCENT = 90;
7const APPROACH_FACTOR = 0.08;
8const MIN_INCREMENT = 0.5;
9
10/**
11 * Indeterminate progress simulator for the preview chrome's loading bar.
12 * Animates 0 → 90% asymptotically while `loading` is true, snaps to 100%
13 * on release, then resets after a short pause.
14 *
15 * Uses a ref to thread the latest progress through interval ticks without
16 * needing `loading` to retrigger the effect, which sidesteps the stale-
17 * closure pitfalls of reading `progress` directly.
18 */
19export function useLoadingProgress(loading: boolean): number {
20 const [progress, setProgress] = useState(0);
21 const progressRef = useRef(0);
22 progressRef.current = progress;
23
24 useEffect(() => {
25 if (!loading) {
26 if (progressRef.current === 0) return;
27 setProgress(100);
28 const timer = window.setTimeout(() => setProgress(0), FADE_OUT_DELAY_MS);
29 return () => window.clearTimeout(timer);
30 }
31
32 setProgress((value) => (value > 0 && value < 95 ? value : SEED_PERCENT));
33 const interval = window.setInterval(() => {
34 const current = progressRef.current;
35 if (current >= ASYMPTOTE_PERCENT) return;
36 const remaining = ASYMPTOTE_PERCENT - current;
37 const increment = Math.max(MIN_INCREMENT, remaining * APPROACH_FACTOR);
38 setProgress(Math.min(ASYMPTOTE_PERCENT, current + increment));
39 }, TICK_INTERVAL_MS);
40
41 return () => window.clearInterval(interval);
42 }, [loading]);
43
44 return progress;
45}
apps/web/src/index.css+35
...@@ -2357,3 +2357,38 @@ code {...@@ -2357,3 +2357,38 @@ code {
2357.ultrathink-chroma {2357.ultrathink-chroma {
2358 animation: ultrathink-chroma-shift 10s linear infinite;2358 animation: ultrathink-chroma-shift 10s linear infinite;
2359}2359}
2360
2361@keyframes preview-loading-progress {
2362 from {
2363 transform: scaleX(0.04);
2364 }
2365
2366 to {
2367 transform: scaleX(0.9);
2368 }
2369}
2370
2371.preview-loading-progress {
2372 opacity: 0;
2373 transform: scaleX(1);
2374 transition:
2375 transform 150ms ease-out,
2376 opacity 150ms ease-out 220ms;
2377}
2378
2379.preview-loading-progress[data-loading="true"] {
2380 opacity: 1;
2381 animation: preview-loading-progress 5.3s cubic-bezier(0.1, 0.5, 0.2, 1) forwards;
2382}
2383
2384@media (prefers-reduced-motion: reduce) {
2385 .preview-loading-progress,
2386 .preview-loading-progress[data-loading="true"] {
2387 transition: none;
2388 animation: none;
2389 }
2390
2391 .preview-loading-progress[data-loading="true"] {
2392 transform: scaleX(0.9);
2393 }
2394}