| author | |
| committer | |
| log | be7d35aaeb49a04483ec5e0d2284e8b5b70a3b6e |
| tree | f962d24ad49ed7e5defad4c63d584ee77b482a26 |
| parent | 549201fcfd3baa6cf92f09e5d868c536c80d2d55 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 135 insertions(+), 63 deletions(-)
apps/web/src/components/preview/PreviewChromeRow.test.tsx-1| ... | ... | @@ -9,7 +9,6 @@ describe("PreviewChromeRow", () => { |
| 9 | 9 | <PreviewChromeRow |
| 10 | 10 | url="https://example.com/dashboard?mode=edit&tab=1#notes" |
| 11 | 11 | loading={false} |
| 12 | loadProgress={0} | |
| 13 | 12 | canGoBack={false} |
| 14 | 13 | canGoForward={false} |
| 15 | 14 | refreshDisabled={false} |
apps/web/src/components/preview/PreviewChromeRow.tsx+6-12| ... | ... | @@ -24,7 +24,6 @@ import { cn } from "~/lib/utils"; |
| 24 | 24 | interface Props { |
| 25 | 25 | url: string; |
| 26 | 26 | loading: boolean; |
| 27 | loadProgress: number; | |
| 28 | 27 | canGoBack: boolean; |
| 29 | 28 | canGoForward: boolean; |
| 30 | 29 | refreshDisabled: boolean; |
| ... | ... | @@ -65,7 +64,6 @@ const NOOP = () => {}; |
| 65 | 64 | export function PreviewChromeRow({ |
| 66 | 65 | url, |
| 67 | 66 | loading, |
| 68 | loadProgress, | |
| 69 | 67 | canGoBack, |
| 70 | 68 | canGoForward, |
| 71 | 69 | refreshDisabled, |
| ... | ... | @@ -308,16 +306,12 @@ export function PreviewChromeRow({ |
| 308 | 306 | ) : null} |
| 309 | 307 | {trailingActions} |
| 310 | 308 | </form> |
| 311 | {loadProgress > 0 ? ( | |
| 312 | <div | |
| 313 | aria-hidden | |
| 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" | |
| 315 | style={{ | |
| 316 | width: `${loadProgress}%`, | |
| 317 | boxShadow: "0 0 6px 1px var(--color-ring)", | |
| 318 | }} | |
| 319 | /> | |
| 320 | ) : null} | |
| 309 | <div | |
| 310 | aria-hidden | |
| 311 | data-loading={loading} | |
| 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" | |
| 313 | style={{ boxShadow: "0 0 6px 1px var(--color-ring)" }} | |
| 314 | /> | |
| 321 | 315 | </div> |
| 322 | 316 | ); |
| 323 | 317 | } |
apps/web/src/components/preview/PreviewView.test.tsx+94-2| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | FILL_PREVIEW_VIEWPORT, |
| 6 | 6 | ThreadId, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | import { act, Profiler } from "react"; | |
| 8 | 9 | import { renderToStaticMarkup } from "react-dom/server"; |
| 9 | 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 11 | |
| ... | ... | @@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({ |
| 30 | 31 | toggleAnnotation: null as (() => void) | null, |
| 31 | 32 | pictureInPicture: false, |
| 32 | 33 | showEmptyState: false, |
| 34 | loading: false, | |
| 33 | 35 | recordVisitForThread: vi.fn(), |
| 34 | 36 | })); |
| 35 | 37 | |
| ... | ... | @@ -103,7 +105,7 @@ vi.mock("~/previewStateStore", () => ({ |
| 103 | 105 | hasWebContents: true, |
| 104 | 106 | canGoBack: false, |
| 105 | 107 | canGoForward: false, |
| 106 | loading: false, | |
| 108 | loading: mocks.loading, | |
| 107 | 109 | zoomFactor: 1, |
| 108 | 110 | pictureInPicture: mocks.pictureInPicture, |
| 109 | 111 | colorScheme: "system", |
| ... | ... | @@ -244,7 +246,6 @@ vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); |
| 244 | 246 | vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); |
| 245 | 247 | vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); |
| 246 | 248 | vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); |
| 247 | vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 })); | |
| 248 | 249 | vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); |
| 249 | 250 | |
| 250 | 251 | import { PreviewView } from "./PreviewView"; |
| ... | ... | @@ -256,6 +257,68 @@ const TEST_THREAD_REF = { |
| 256 | 257 | } as const; |
| 257 | 258 | const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1"); |
| 258 | 259 | |
| 260 | // ReactDOM needs a host, but this unit suite intentionally has no DOM dependency. | |
| 261 | class 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 | ||
| 303 | function 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 | ||
| 259 | 322 | describe("PreviewView navigation", () => { |
| 260 | 323 | beforeEach(() => { |
| 261 | 324 | mocks.navigate.mockClear(); |
| ... | ... | @@ -279,9 +342,38 @@ describe("PreviewView navigation", () => { |
| 279 | 342 | mocks.toggleAnnotation = null; |
| 280 | 343 | mocks.pictureInPicture = false; |
| 281 | 344 | mocks.showEmptyState = false; |
| 345 | mocks.loading = false; | |
| 282 | 346 | mocks.recordVisitForThread.mockClear(); |
| 283 | 347 | }); |
| 284 | 348 | |
| 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 | 377 | it.each([ |
| 286 | 378 | [ |
| 287 | 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 | 50 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| 51 | 51 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 52 | 52 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| 53 | import { useLoadingProgress } from "./useLoadingProgress"; | |
| 54 | 53 | import { usePreviewSession } from "./usePreviewSession"; |
| 55 | 54 | import { ZoomIndicator } from "./ZoomIndicator"; |
| 56 | 55 | import { AgentBrowserCursor } from "./AgentBrowserCursor"; |
| ... | ... | @@ -142,7 +141,6 @@ export function PreviewView({ |
| 142 | 141 | const isUnreachable = navStatus._tag === "LoadFailed"; |
| 143 | 142 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); |
| 144 | 143 | const controller = desktopOverlay?.controller ?? "none"; |
| 145 | const loadProgress = useLoadingProgress(loading); | |
| 146 | 144 | const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; |
| 147 | 145 | const browserDefaults = useBrowserDefaults(); |
| 148 | 146 | const panelRect = useBrowserSurfaceStore((state) => |
| ... | ... | @@ -661,7 +659,6 @@ export function PreviewView({ |
| 661 | 659 | <PreviewChromeRow |
| 662 | 660 | url={url} |
| 663 | 661 | loading={loading} |
| 664 | loadProgress={loadProgress} | |
| 665 | 662 | canGoBack={canGoBack} |
| 666 | 663 | canGoForward={canGoForward} |
| 667 | 664 | refreshDisabled={refreshDisabled} |
apps/web/src/components/preview/useLoadingProgress.ts deleted-45| ... | ... | @@ -1,45 +0,0 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | ||
| 3 | const TICK_INTERVAL_MS = 120; | |
| 4 | const FADE_OUT_DELAY_MS = 220; | |
| 5 | const SEED_PERCENT = 4; | |
| 6 | const ASYMPTOTE_PERCENT = 90; | |
| 7 | const APPROACH_FACTOR = 0.08; | |
| 8 | const 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 | */ | |
| 19 | export 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 | 2357 | .ultrathink-chroma { |
| 2358 | 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 | } |