From eb6da933506d06666613bb84daf9206b92f1039b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:23:49 -0700 Subject: [PATCH 1/5] fix(web): browser viewport resizes work without an open preview panel Viewport commits from the hosted webview's resize handles and device toolbar went through a handler that only a mounted PreviewView registered, so with no PreviewView for that tab they failed with "No visible browser viewport handler". Commits now call the resize command directly, still serialized per runtime tab. Also moves three pieces of preview sync out of React: - Browser profile reporting starts with the app instead of rendering a null component, and reports each operable server-browser environment once per server config and profile settings instead of every environment whenever any of them changed. - Page titles reach browser history from the preview store, for every thread, instead of an effect in the open PreviewView. The pending-title buffer goes away: registration applies the thread's current settled titles. - A failed preview-stream access lookup retries from its atom while the surface is visible, replacing an interval effect. The focus-url shortcut focuses the address bar directly instead of bumping a nonce that PreviewChromeRow watched in an effect. Effects: PreviewView 7 -> 5, PreviewChromeRow 1 -> 0, ServerBrowserSurface 5 -> 4, BrowserProfileReporter 1 -> 0 (file replaced by a start function). Co-Authored-By: Claude Opus 5.5 --- apps/web/src/AppRoot.tsx | 2 - apps/web/src/appInit.tsx | 4 + .../browser/BrowserProfileReporter.test.ts | 64 +++++++++++ .../web/src/browser/BrowserProfileReporter.ts | 106 ++++++++++++++++++ .../src/browser/BrowserProfileReporter.tsx | 42 ------- apps/web/src/browser/HostedBrowserWebview.tsx | 2 +- apps/web/src/browser/ServerBrowserSurface.tsx | 9 +- .../browser/browserViewportActions.test.ts | 76 ++++++++++--- .../web/src/browser/browserViewportActions.ts | 74 ++++++++---- .../src/browser/useBrowserViewportResize.ts | 11 +- apps/web/src/browserHistoryStore.test.ts | 70 ++++++++---- apps/web/src/browserHistoryStore.ts | 92 ++++++++------- .../components/preview/PreviewChromeRow.tsx | 16 +-- .../preview/PreviewView.browser.test.tsx | 15 +++ .../src/components/preview/PreviewView.tsx | 79 +++---------- apps/web/src/hooks/useSettings.ts | 4 +- apps/web/src/state/previewStream.ts | 27 ++++- 17 files changed, 455 insertions(+), 238 deletions(-) create mode 100644 apps/web/src/browser/BrowserProfileReporter.test.ts create mode 100644 apps/web/src/browser/BrowserProfileReporter.ts delete mode 100644 apps/web/src/browser/BrowserProfileReporter.tsx diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 5e5b5ff16be90..e62b89a40e42b 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -5,7 +5,6 @@ import { subscribeComposerDraftModelReconciliation } from "./composerDraftStore" import { environmentThreadShells } from "./state/threads"; import { environmentShell } from "./state/shell"; -import { BrowserProfileReporter } from "./browser/BrowserProfileReporter"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { appAtomRegistry, AppAtomRegistryProvider } from "./rpc/atomRegistry"; @@ -27,7 +26,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { - ); diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 55cea5c1146e8..3f909b26ff475 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -5,6 +5,8 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router" import { applyAppearanceContrast } from "./appearanceContrast"; import { applyAppearanceFontVariables } from "./appearanceFonts"; import { startElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter"; +import { startPreviewTitleRecording } from "./browserHistoryStore"; import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention"; import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer"; import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog"; @@ -144,6 +146,8 @@ export function startAppServices(): AppRouter { startModelPreferencesMigration(); startAgentOpenedPreviews(); if (isElectron) startElectronBrowserHost(); + startPreviewTitleRecording(); + startBrowserProfileReporting(); startThreadOverlayRetention(router); startMainAppLocationTracking(router); startBackgroundThreadAnnouncements(appAtomRegistry, router); diff --git a/apps/web/src/browser/BrowserProfileReporter.test.ts b/apps/web/src/browser/BrowserProfileReporter.test.ts new file mode 100644 index 0000000000000..cd715c8120229 --- /dev/null +++ b/apps/web/src/browser/BrowserProfileReporter.test.ts @@ -0,0 +1,64 @@ +import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + type ProfileReport, + type ReportedProfiles, + takeDueProfileReports, +} from "./BrowserProfileReporter"; + +const first = EnvironmentId.make("environment-1"); +const second = EnvironmentId.make("environment-2"); +const config = (label: string) => ({ label }) as unknown as EnvironmentServerConfig; +const input = (recordingFrameRate: number) => + ({ profiles: [], defaultProfileId: "default", recordingFrameRate }) as unknown as ProfileReport; + +describe("takeDueProfileReports", () => { + it("reports each environment once per config and input", () => { + const reported: ReportedProfiles = new Map(); + const configA = config("a"); + const configB = config("b"); + const settings = input(10); + const targets = new Map([ + [first, configA], + [second, configB], + ]); + + expect(takeDueProfileReports(reported, targets, settings)).toEqual([first, second]); + expect(takeDueProfileReports(reported, new Map(targets), settings)).toEqual([]); + + const restarted = config("a-restarted"); + expect( + takeDueProfileReports( + reported, + new Map([ + [first, restarted], + [second, configB], + ]), + settings, + ), + ).toEqual([first]); + }); + + it("reports every target again when the profile settings change", () => { + const reported: ReportedProfiles = new Map(); + const targets = new Map([ + [first, config("a")], + [second, config("b")], + ]); + takeDueProfileReports(reported, targets, input(10)); + + expect(takeDueProfileReports(reported, targets, input(30))).toEqual([first, second]); + }); + + it("reports an environment again once it becomes operable again", () => { + const reported: ReportedProfiles = new Map(); + const configA = config("a"); + const settings = input(10); + takeDueProfileReports(reported, new Map([[first, configA]]), settings); + + expect(takeDueProfileReports(reported, new Map(), settings)).toEqual([]); + expect(takeDueProfileReports(reported, new Map([[first, configA]]), settings)).toEqual([first]); + }); +}); diff --git a/apps/web/src/browser/BrowserProfileReporter.ts b/apps/web/src/browser/BrowserProfileReporter.ts new file mode 100644 index 0000000000000..89d3f8be69d33 --- /dev/null +++ b/apps/web/src/browser/BrowserProfileReporter.ts @@ -0,0 +1,106 @@ +import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { AuthPreviewOperateScope, type EnvironmentId } from "@t3tools/contracts"; +import type { ClientSettings } from "@t3tools/contracts/settings"; +import { Atom } from "effect/reactivity"; + +import { + getClientSettings, + getClientSettingsHydratedSnapshot, + subscribeClientSettings, + subscribeClientSettingsHydration, +} from "~/hooks/useSettings"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { environmentPresentations } from "~/state/presentation"; +import { previewEnvironment } from "~/state/preview"; +import { environmentSession, sessionHasScope } from "~/state/session"; + +export interface ProfileReport { + readonly profiles: ClientSettings["browserProfiles"]; + readonly defaultProfileId: ClientSettings["browserDefaultProfileId"]; + readonly recordingFrameRate: ClientSettings["browserRecordingFrameRate"]; +} + +/** Server-browser environments this client may operate, with the config each currently runs. */ +const reportTargetsAtom = Atom.make((get) => { + const targets = new Map(); + for (const [environmentId, config] of get(environmentPresentations.serverConfigsAtom)) { + if (!config.environment.capabilities.serverBrowser) continue; + const session = get(environmentSession.sessionStateAtom(environmentId)); + if (!sessionHasScope(session, AuthPreviewOperateScope)) continue; + targets.set(environmentId, config); + } + return targets; +}).pipe(Atom.withLabel("browser-profile-report-targets")); + +/** + * Keeps every operable server browser on this client's profiles. An environment is reported + * again when its server config changes (a restarted server has forgotten them), when it becomes + * operable, and when the profile settings change. + */ +export function startBrowserProfileReporting(): () => void { + const reported: ReportedProfiles = new Map(); + const report = () => { + if (!getClientSettingsHydratedSnapshot()) return; + const settings = getClientSettings(); + const input: ProfileReport = { + profiles: settings.browserProfiles, + defaultProfileId: settings.browserDefaultProfileId, + recordingFrameRate: settings.browserRecordingFrameRate, + }; + const targets = appAtomRegistry.get(reportTargetsAtom); + for (const environmentId of takeDueProfileReports(reported, targets, input)) { + void runAtomCommand( + appAtomRegistry, + previewEnvironment.reportProfiles, + { environmentId, input }, + { reportFailure: false }, + ); + } + }; + const unsubscribes = [ + appAtomRegistry.subscribe(reportTargetsAtom, report), + subscribeClientSettings(report), + subscribeClientSettingsHydration(report), + ]; + report(); + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; +} + +export type ReportedProfiles = Map< + EnvironmentId, + { readonly config: EnvironmentServerConfig; readonly input: ProfileReport } +>; + +/** + * Returns the targets that need `input` reported and records them in `reported`. An + * environment that dropped out of `targets` is forgotten, so it is reported again once it + * becomes operable. + */ +export function takeDueProfileReports( + reported: ReportedProfiles, + targets: ReadonlyMap, + input: ProfileReport, +): EnvironmentId[] { + for (const environmentId of reported.keys()) { + if (!targets.has(environmentId)) reported.delete(environmentId); + } + const due: EnvironmentId[] = []; + for (const [environmentId, config] of targets) { + const last = reported.get(environmentId); + if (last && last.config === config && sameReportInput(last.input, input)) continue; + reported.set(environmentId, { config, input }); + due.push(environmentId); + } + return due; +} + +function sameReportInput(left: ProfileReport, right: ProfileReport): boolean { + return ( + left.profiles === right.profiles && + left.defaultProfileId === right.defaultProfileId && + left.recordingFrameRate === right.recordingFrameRate + ); +} diff --git a/apps/web/src/browser/BrowserProfileReporter.tsx b/apps/web/src/browser/BrowserProfileReporter.tsx deleted file mode 100644 index 1839a35aac213..0000000000000 --- a/apps/web/src/browser/BrowserProfileReporter.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { - AuthPreviewOperateScope, - type BrowserProfile, - type BrowserRecordingFrameRate, -} from "@t3tools/contracts"; -import { useEffect } from "react"; - -import { useClientSettings, useClientSettingsHydrated } from "~/hooks/useSettings"; -import { useServerConfigs } from "~/state/entities"; -import { useEnvironments } from "~/state/environments"; -import { previewEnvironment } from "~/state/preview"; -import { useEnvironmentsWithScope } from "~/state/session"; -import { useAtomCommand } from "~/state/use-atom-command"; - -const selectProfiles = (settings: { readonly browserProfiles: ReadonlyArray }) => - settings.browserProfiles; -const selectDefaultProfileId = (settings: { readonly browserDefaultProfileId: string }) => - settings.browserDefaultProfileId; -const selectRecordingFrameRate = (settings: { - readonly browserRecordingFrameRate: BrowserRecordingFrameRate; -}) => settings.browserRecordingFrameRate; - -export function BrowserProfileReporter() { - const hydrated = useClientSettingsHydrated(); - const profiles = useClientSettings(selectProfiles); - const defaultProfileId = useClientSettings(selectDefaultProfileId); - const recordingFrameRate = useClientSettings(selectRecordingFrameRate); - const { environments } = useEnvironments(); - const serverConfigs = useServerConfigs(); - const operable = useEnvironmentsWithScope(environments, AuthPreviewOperateScope); - const report = useAtomCommand(previewEnvironment.reportProfiles, { reportFailure: false }); - - useEffect(() => { - if (!hydrated) return; - for (const [environmentId, config] of serverConfigs) { - if (!config.environment.capabilities.serverBrowser || !operable.has(environmentId)) continue; - void report({ environmentId, input: { profiles, defaultProfileId, recordingFrameRate } }); - } - }, [defaultProfileId, hydrated, operable, profiles, recordingFrameRate, report, serverConfigs]); - - return null; -} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 2865b52036020..cd1852d369279 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -236,7 +236,7 @@ export function HostedBrowserWebview(props: { handleResizePointerDown, layout: viewportLayout, } = useBrowserViewportResize({ - tabId: runtimeTabId, + target: { threadRef, tabId, runtimeTabId }, viewport, zoomFactor: layoutZoomFactor, containerSize, diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 93c9cbcc9b26c..45c84dba0e71f 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -49,7 +49,6 @@ export interface ServerBrowserHandle { } const RESIZE_THROTTLE_MS = 150; -const ACCESS_RETRY_MS = 10_000; // Reuse nearby probes when a slow link would return the tap's answer after it ends. const PROBE_REUSE_PX = 24; const PROBE_REUSE_MS = 10_000; @@ -161,7 +160,6 @@ export function ServerBrowserSurface(props: { pdfUrl = null, ref, } = props; - const access = usePreviewStreamAccess(environmentId); const canvasRef = useRef(null); const inputRef = useRef(null); const keySentRef = useRef(false); @@ -189,6 +187,7 @@ export function ServerBrowserSurface(props: { }; const unauthorizedRef = useRef(0); const [accessDenied, setAccessDenied] = useState(false); + const access = usePreviewStreamAccess(environmentId, { retry: visible && !accessDenied }); const [hostSetup, setHostSetup] = useState(null); const pendingMoveRef = useRef(null); const pendingWheelRef = useRef(null); @@ -458,12 +457,6 @@ export function ServerBrowserSurface(props: { visible, ]); - useEffect(() => { - if (!visible || accessDenied || access !== null) return; - const timer = setInterval(() => refreshPreviewStreamAccess(environmentId), ACCESS_RETRY_MS); - return () => clearInterval(timer); - }, [access, accessDenied, environmentId, visible]); - useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index a441eecf08ddb..a744305330dbe 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -1,9 +1,30 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { - commitBrowserViewportChange, - subscribeBrowserViewportChange, -} from "./browserViewportActions"; + EnvironmentId, + type PreviewViewportSetting, + ThreadId, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; + +const mocks = vi.hoisted(() => ({ + resize: Symbol("preview-resize"), + runAtomCommand: vi.fn(), + updatePreviewServerSnapshot: vi.fn(), +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + runAtomCommand: mocks.runAtomCommand, + squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, +})); +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("~/state/preview", () => ({ previewEnvironment: { resize: mocks.resize } })); +vi.mock("~/previewStateStore", () => ({ + updatePreviewServerSnapshot: mocks.updatePreviewServerSnapshot, +})); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { commitBrowserViewportChange, queueBrowserViewportCommit } from "./browserViewportActions"; describe("browserViewportActions", () => { it("commits viewport changes in order for each tab", async () => { @@ -16,31 +37,52 @@ describe("browserViewportActions", () => { markFirstStarted = resolve; }); const calls: Array = []; - const unsubscribe = subscribeBrowserViewportChange("tab-serial", async (setting) => { + const resize = async (setting: PreviewViewportSetting) => { if (setting._tag === "fill") return; calls.push(setting.width); if (setting.width === 800) { markFirstStarted?.(); await firstPending; } - }); + }; - const first = commitBrowserViewportChange("tab-serial", { - _tag: "freeform", - width: 800, - height: 600, - }); - const second = commitBrowserViewportChange("tab-serial", { - _tag: "freeform", - width: 900, - height: 700, - }); + const first = queueBrowserViewportCommit("tab-serial", () => + resize({ _tag: "freeform", width: 800, height: 600 }), + ); + const second = queueBrowserViewportCommit("tab-serial", () => + resize({ _tag: "freeform", width: 900, height: 700 }), + ); await firstStarted; expect(calls).toEqual([800]); releaseFirst?.(); await Promise.all([first, second]); expect(calls).toEqual([800, 900]); - unsubscribe(); + }); + + it("resizes a tab that no preview panel is showing", async () => { + const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + }; + const snapshot = { tabId: "tab-1" } as PreviewSessionSnapshot; + mocks.runAtomCommand.mockResolvedValueOnce({ _tag: "Success", value: snapshot }); + const viewport: PreviewViewportSetting = { _tag: "freeform", width: 390, height: 844 }; + + await commitBrowserViewportChange( + { threadRef, tabId: "tab-1", runtimeTabId: "runtime-tab-1" }, + viewport, + ); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.resize, + { + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, tabId: "tab-1", viewport }, + }, + expect.anything(), + ); + expect(mocks.updatePreviewServerSnapshot).toHaveBeenCalledWith(threadRef, snapshot); }); }); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index a1dc59f6dbf18..eddee7f5b1e75 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -1,6 +1,17 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; +import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; -type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise; +import { toastManager } from "~/components/ui/toast"; +import { updatePreviewServerSnapshot } from "~/previewStateStore"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { previewEnvironment } from "~/state/preview"; + +/** One browser tab as both the server (thread + tab id) and this window's runtime know it. */ +export interface BrowserViewportTarget { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly runtimeTabId: string; +} export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; @@ -12,7 +23,6 @@ class BrowserViewportCommitTimeoutError extends Error { } } -const handlers = new Map(); const commitTails = new Map>(); const queueBrowserViewportMutation = ( @@ -59,27 +69,49 @@ const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise }); }; -export function subscribeBrowserViewportChange( - tabId: string, - handler: BrowserViewportHandler, -): () => void { - handlers.set(tabId, handler); - return () => { - if (handlers.get(tabId) === handler) handlers.delete(tabId); - }; +async function resizeBrowserViewport( + target: BrowserViewportTarget, + viewport: PreviewViewportSetting, +): Promise { + const result = await runAtomCommand( + appAtomRegistry, + previewEnvironment.resize, + { + environmentId: target.threadRef.environmentId, + input: { threadId: target.threadRef.threadId, tabId: target.tabId, viewport }, + }, + { label: "preview viewport resize" }, + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Unable to resize browser viewport", + description: error instanceof Error ? error.message : "An error occurred.", + }); + throw error; + } + updatePreviewServerSnapshot(target.threadRef, result.value); +} + +/** + * Runs `operation` after every earlier commit for the runtime tab. The + * caller-facing timeout starts only once it reaches the front of the queue, + * while the queue itself waits for the real operation. + */ +export function queueBrowserViewportCommit( + runtimeTabId: string, + operation: () => Promise, +): Promise { + const { started } = queueBrowserViewportMutation(runtimeTabId, operation); + return started.then(({ operation: running }) => runHandlerWithTimeout(runtimeTabId, running)); } export function commitBrowserViewportChange( - tabId: string, + target: BrowserViewportTarget, setting: PreviewViewportSetting, ): Promise { - const { started } = queueBrowserViewportMutation(tabId, () => { - const handler = handlers.get(tabId); - return handler - ? handler(setting) - : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); - }); - // Queue until the handler finishes; start the caller timeout only when its turn begins. - const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); - return result; + return queueBrowserViewportCommit(target.runtimeTabId, () => + resizeBrowserViewport(target, setting), + ); } diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts index bee012aa6d70d..6fc24b546896c 100644 --- a/apps/web/src/browser/useBrowserViewportResize.ts +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -8,7 +8,7 @@ import { } from "react"; import { useStableCallback } from "../hooks/useStableCallback"; -import { commitBrowserViewportChange } from "./browserViewportActions"; +import { type BrowserViewportTarget, commitBrowserViewportChange } from "./browserViewportActions"; import { browserViewportSettingKey, resizeBrowserViewportFromRail, @@ -27,14 +27,15 @@ interface ViewportDrag extends PreviewViewportSize { const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150; export function useBrowserViewportResize(options: { - readonly tabId: string; + readonly target: BrowserViewportTarget; readonly viewport: PreviewViewportSetting; readonly zoomFactor: number; readonly containerSize: PreviewViewportSize; readonly deviceToolbarVisible: boolean; readonly aspectRatio: number | null; }) { - const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } = options; + const { target, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } = + options; const dragCleanupRef = useRef<(() => void) | null>(null); const dragVersionRef = useRef(0); const keyboardCommitTimerRef = useRef | null>(null); @@ -91,7 +92,7 @@ export function useBrowserViewportResize(options: { } keyboardViewportRef.current = null; setDragViewport(null); - return commitBrowserViewportChange(tabId, next); + return commitBrowserViewportChange(target, next); }; const clearDrag = () => setDragViewport(null); @@ -100,7 +101,7 @@ export function useBrowserViewportResize(options: { const clearIfCurrent = () => { if (dragVersionRef.current === version) clearDrag(); }; - void commitBrowserViewportChange(tabId, next).then(clearIfCurrent, clearIfCurrent); + void commitBrowserViewportChange(target, next).then(clearIfCurrent, clearIfCurrent); }; const handleResizeKeyDown = ( diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts index 473eeadf5e24e..d070bbf0a1455 100644 --- a/apps/web/src/browserHistoryStore.test.ts +++ b/apps/web/src/browserHistoryStore.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; const { readPreparedConnection } = vi.hoisted(() => ({ readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null), @@ -16,10 +16,11 @@ import { normalizeHistoryUrl, recordVisitForThread, resetBrowserHistoryForTests, - setTitleForThreadUrl, + startPreviewTitleRecording, upsertHistoryEntry, useBrowserHistoryStore, } from "./browserHistoryStore"; +import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore"; function entry(overrides: Partial = {}): BrowserHistoryEntry { return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides }; @@ -108,24 +109,54 @@ const threadRef = { threadId: ThreadId.make("thread-1"), }; +let settledAt = 0; + +function settle(url: string, title: string): void { + settledAt += 1; + const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab_a", + navStatus: { _tag: "Success", url, title }, + canGoBack: false, + canGoForward: false, + updatedAt: new Date(settledAt).toISOString(), + }; + applyPreviewServerSnapshot(threadRef, snapshot); +} + +function resetStores(): () => void { + resetBrowserHistoryForTests(); + resetPreviewStateForTests(); + return startPreviewTitleRecording(); +} + describe("useBrowserHistoryStore", () => { - beforeEach(() => { - resetBrowserHistoryForTests(); - }); + beforeEach(resetStores); - it("sets titles update-only via the thread helper", () => { + it("titles only pages already in history when they settle", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); + settle("http://a.test/", "Should not create"); expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); recordVisitForThread(threadRef, "http://a.test/#/settings", 1); - setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App"); + settle("http://a.test/#/settings", "My App"); expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); }); + it("titles a visit recorded after its page already settled", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://other.test/", 1); + settle("http://a.test/", "My App"); + recordVisitForThread(threadRef, "http://a.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/", + title: "My App", + }); + }); + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://a.test/community", 1); - setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + settle("http://a.test/community/", "Community"); expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community", @@ -133,7 +164,7 @@ describe("useBrowserHistoryStore", () => { useBrowserHistoryStore.setState({ byProjectKey: {} }); recordVisitForThread(threadRef, "http://a.test/community/", 1); - setTitleForThreadUrl(threadRef, "http://a.test/community", "Community"); + settle("http://a.test/community", "Community"); expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ url: "http://a.test/community/", title: "Community", @@ -143,7 +174,8 @@ describe("useBrowserHistoryStore", () => { it("matches a requested localhost URL to the resolved environment host", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://localhost:5173/app", 1); - setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + settle("http://192.168.64.2:5173/app", "Local App"); expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); }); @@ -167,9 +199,7 @@ describe("useBrowserHistoryStore", () => { }); describe("pendingVisitsByThreadKey", () => { - beforeEach(() => { - resetBrowserHistoryForTests(); - }); + beforeEach(resetStores); it("queues a visit recorded before registration and drains it in order on registration", () => { recordVisitForThread(threadRef, "http://a.test/", 1); @@ -201,21 +231,18 @@ describe("pendingVisitsByThreadKey", () => { }); }); -describe("pendingTitlesByThreadKey", () => { - beforeEach(() => { - resetBrowserHistoryForTests(); - }); +describe("titles before registration", () => { + beforeEach(resetStores); - it("buffers a title set before registration and applies it once the matching visit drains", () => { + it("applies a settled page's title once the matching visit drains", () => { recordVisitForThread(threadRef, "http://a.test/", 1); - setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + settle("http://a.test/", "My App"); expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); - expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); }); }); @@ -238,6 +265,5 @@ describe("mergeBrowserHistoryState", () => { expect(typeof merged.recordVisit).toBe("function"); expect(merged.projectKeyByThreadKey).toEqual({ good: "b" }); expect(merged.pendingVisitsByThreadKey).toEqual({}); - expect(merged.pendingTitlesByThreadKey).toEqual({}); }); }); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index 7909fef957001..9dd33ad013ea1 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -5,6 +5,13 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { + previewStateAtom, + previewThreadRefsAtom, + readThreadPreviewState, + type ThreadPreviewState, +} from "~/previewStateStore"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { readPreparedConnection } from "~/state/session"; import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; @@ -144,13 +151,11 @@ const PENDING_MAX_PER_THREAD = 10; const PENDING_MAX_THREADS = 20; type PendingVisit = { url: string; at: number; environmentHostname: string | null }; -type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined }; interface BrowserHistoryStoreState { byProjectKey: Record; projectKeyByThreadKey: Record; pendingVisitsByThreadKey: Record; - pendingTitlesByThreadKey: Record; recordVisit: ( projectKey: string, url: string, @@ -189,7 +194,6 @@ export const useBrowserHistoryStore = create()( byProjectKey: {}, projectKeyByThreadKey: {}, pendingVisitsByThreadKey: {}, - pendingTitlesByThreadKey: {}, recordVisit: (projectKey, url, at, options) => { const normalized = normalizeHistoryUrl(url); if (!normalized) return; @@ -245,35 +249,19 @@ export const useBrowserHistoryStore = create()( const threadKey = scopedThreadKey(ref); const state = get(); const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; - const pendingTitles = state.pendingTitlesByThreadKey[threadKey]; - if ( - state.projectKeyByThreadKey[threadKey] === projectKey && - !pendingVisits && - !pendingTitles - ) { - return; - } + if (state.projectKeyByThreadKey[threadKey] === projectKey && !pendingVisits) return; const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; - const nextPendingTitles = { ...state.pendingTitlesByThreadKey }; delete nextPendingVisits[threadKey]; - delete nextPendingTitles[threadKey]; set({ projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, pendingVisitsByThreadKey: nextPendingVisits, - pendingTitlesByThreadKey: nextPendingTitles, }); for (const visit of pendingVisits ?? []) get().recordVisit(projectKey, visit.url, visit.at, { insertOrdered: true, environmentHostname: visit.environmentHostname, }); - for (const pendingTitle of pendingTitles ?? []) - get().setTitleForUrl( - projectKey, - pendingTitle.url, - pendingTitle.title, - pendingTitle.environmentHostname, - ); + recordPreviewTitles(ref, readThreadPreviewState(ref)); }, }), { @@ -302,7 +290,6 @@ export function mergeBrowserHistoryState( ...migrated, projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), pendingVisitsByThreadKey: {}, - pendingTitlesByThreadKey: {}, }; } @@ -341,28 +328,52 @@ export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: num return; } state.recordVisit(projectKey, url, visitAt, { environmentHostname }); + // A page can settle before the address bar records its visit. + recordPreviewTitles(ref, readThreadPreviewState(ref)); } -export function setTitleForThreadUrl( - ref: ScopedThreadRef, - url: string, - title: string, - environmentHostname?: string | null, -): void { - const threadKey = scopedThreadKey(ref); +/** + * Titles only update entries already in the thread's project history, so they + * are applied both when a page settles and when its visit is recorded. + */ +function recordPreviewTitles(ref: ScopedThreadRef, preview: ThreadPreviewState): void { const state = useBrowserHistoryStore.getState(); - const projectKey = state.projectKeyByThreadKey[threadKey]; - if (!projectKey) { - useBrowserHistoryStore.setState({ - pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, { - url, - title, - environmentHostname, - }), - }); - return; + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + if (!projectKey || !state.byProjectKey[projectKey]?.length) return; + const connection = readPreparedConnection(ref.environmentId); + const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; + for (const session of Object.values(preview.sessions)) { + const status = session.navStatus; + if (status._tag !== "Success" || !status.title) continue; + state.setTitleForUrl(projectKey, status.url, status.title, environmentHostname); } - state.setTitleForUrl(projectKey, url, title, environmentHostname); +} + +/** Records settled page titles for every thread with previews, for the life of the page. */ +export function startPreviewTitleRecording(): () => void { + const followed = new Map void>(); + const followNewThreads = (refs: ReadonlyMap) => { + for (const [threadKey, threadRef] of refs) { + if (followed.has(threadKey)) continue; + const atom = previewStateAtom(threadKey); + let previousSessions = appAtomRegistry.get(atom).sessions; + followed.set( + threadKey, + appAtomRegistry.subscribe(atom, (next) => { + if (next.sessions === previousSessions) return; + previousSessions = next.sessions; + recordPreviewTitles(threadRef, next); + }), + ); + } + }; + const stopIndex = appAtomRegistry.subscribe(previewThreadRefsAtom, followNewThreads, { + immediate: true, + }); + return () => { + stopIndex(); + for (const stop of followed.values()) stop(); + }; } export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { @@ -392,7 +403,6 @@ export function resetBrowserHistoryForTests(): void { byProjectKey: {}, projectKeyByThreadKey: {}, pendingVisitsByThreadKey: {}, - pendingTitlesByThreadKey: {}, }); useBrowserHistoryStore.persist.clearStorage(); } diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 5df55e06d4f4a..1fb95a94f3fbd 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -11,8 +11,7 @@ import { type FormEvent, type KeyboardEvent, type ReactNode, - useEffect, - useRef, + type RefObject, useState, } from "react"; @@ -28,7 +27,8 @@ interface Props { canGoForward: boolean; refreshDisabled: boolean; inputDisabled?: boolean | undefined; - focusUrlNonce?: number | undefined; + /** Owned by the caller so its shortcut handler can focus the address bar directly. */ + urlInputRef: RefObject; onBack: () => void; onForward: () => void; onRefresh: () => void; @@ -57,7 +57,7 @@ export function PreviewChromeRow({ canGoForward, refreshDisabled, inputDisabled, - focusUrlNonce, + urlInputRef: inputRef, onBack, onForward, onRefresh, @@ -76,17 +76,9 @@ export function PreviewChromeRow({ trailingActions, leadingActions, }: Props) { - const inputRef = useRef(null); const [draft, setDraft] = useState(url); const [inputFocused, setInputFocused] = useState(false); - useEffect(() => { - if (focusUrlNonce == null) return; - const node = inputRef.current; - if (!node) return; - node.focus(); - }, [focusUrlNonce]); - const submit = (event?: FormEvent | KeyboardEvent) => { event?.preventDefault(); const next = draft.trim(); diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index e62c5defa7974..c17655db964bc 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -160,6 +160,7 @@ import { useComposerDraftStore } from "~/composerDraftStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { dispatchPreviewAction } from "./previewActionBus"; import { PreviewView } from "./PreviewView"; const TEST_THREAD_REF = { @@ -271,4 +272,18 @@ describe("PreviewView", () => { expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, sent); expect(mocks.addImage).not.toHaveBeenCalled(); }); + + it("focuses the address bar each time the focus-url shortcut fires", async () => { + setup(); + await using _screen = await render(view()); + const address = page.getByRole("textbox"); + + act(() => dispatchPreviewAction("focus-url")); + await expect.element(address).toHaveFocus(); + + (address.element() as HTMLInputElement).blur(); + await expect.element(address).not.toHaveFocus(); + act(() => dispatchPreviewAction("focus-url")); + await expect.element(address).toHaveFocus(); + }); }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 65a0290ed3803..174c49c8cb60b 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,4 +1,4 @@ -import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream"; import { @@ -10,7 +10,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, type PreviewAnnotationPayload, - type PreviewViewportSetting, type ScopedThreadRef, DEFAULT_PREVIEW_ZOOM_FACTOR, PREVIEW_ZOOM_LEVELS, @@ -28,7 +27,6 @@ import { BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, recordVisitForThread, removeUrlForThread, - setTitleForThreadUrl, useThreadRecentHistory, } from "~/browserHistoryStore"; import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; @@ -66,10 +64,7 @@ import { showPreviewPopup } from "./showPreviewPopup"; import { PreviewChromeRow } from "./PreviewChromeRow"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu"; -import { - commitBrowserViewportChange, - subscribeBrowserViewportChange, -} from "~/browser/browserViewportActions"; +import { commitBrowserViewportChange } from "~/browser/browserViewportActions"; import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; @@ -150,7 +145,7 @@ export function PreviewView({ visible, onSendAnnotation, }: Props) { - const [focusUrlNonce, setFocusUrlNonce] = useState(undefined); + const urlInputRef = useRef(null); const [pickActive, setPickActive] = useState(false); const canSendAnnotation = useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) && @@ -169,15 +164,11 @@ export function PreviewView({ const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); - const environmentHostname = environmentHttpBaseUrl - ? new URL(environmentHttpBaseUrl).hostname - : null; const open = useAtomCommand(previewEnvironment.open); const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const environmentLabel = useEnvironment(threadRef.environmentId)?.label ?? "the environment"; const primaryEnvironmentId = usePrimaryEnvironmentId(); const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId); - const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom"); usePreviewSession(threadRef); @@ -205,10 +196,10 @@ export function PreviewView({ const serverOwnsRendering = snapshot?.runtime === "server"; const serverSurfaceRef = useRef(null); const [serverAspectRatioLocked, setServerAspectRatioLocked] = useState(false); - const serverBodyRef = useRef(null); const [serverToolbarWidth, setServerToolbarWidth] = useState(0); const serverToolbarShown = snapshot?.runtime === "server" && (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT)._tag !== "fill"; + const serverBodyRef = useRef(null); useEffect(() => { const element = serverBodyRef.current; if (!element || !serverToolbarShown) return; @@ -241,20 +232,6 @@ export function PreviewView({ runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); - const navUrl = navStatus._tag === "Success" ? navStatus.url : null; - const navTitle = navStatus._tag === "Success" ? navStatus.title : null; - const latestHistoryUrl = recentHistoryEntries[0]?.url; - const { environmentId: threadEnvironmentId, threadId } = threadRef; - useEffect(() => { - if (!navUrl || !navTitle || !latestHistoryUrl) return; - setTitleForThreadUrl( - scopeThreadRef(threadEnvironmentId, threadId), - navUrl, - navTitle, - environmentHostname, - ); - }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadEnvironmentId, threadId]); - const navigateToResolvedUrl = async (resolvedUrl: string) => { if (isServerTab && serverSurfaceRef.current) { if (serverInputDisabled) return false; @@ -355,37 +332,16 @@ export function PreviewView({ else if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId); }; - const handleViewportChange = async (nextViewport: PreviewViewportSetting) => { - if (!tabId) return; - const result = await resize({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - tabId, - viewport: nextViewport, - }, - }); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Unable to resize browser viewport", - description: error instanceof Error ? error.message : "An error occurred.", - }); - throw error; - } - updatePreviewServerSnapshot(threadRef, result.value); - }; - const handleToggleDeviceToolbar = () => { - if (!runtimeTabId) return; + if (!runtimeTabId || !tabId) return; + const target = { threadRef, tabId, runtimeTabId }; if (viewport._tag !== "fill") { - void commitBrowserViewportChange(runtimeTabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined); + void commitBrowserViewportChange(target, FILL_PREVIEW_VIEWPORT).catch(() => undefined); return; } void commitBrowserViewportChange( - runtimeTabId, + target, browserResponsiveViewportForToggle({ defaults: browserDefaults, panelRect, @@ -394,14 +350,6 @@ export function PreviewView({ ).catch(() => undefined); }; - const onViewportChange = useEffectEvent(handleViewportChange); - useEffect(() => { - if (!runtimeTabId) return; - return subscribeBrowserViewportChange(runtimeTabId, (nextViewport) => - onViewportChange(nextViewport), - ); - }, [runtimeTabId]); - const handleBack = () => { if (isServerTab) serverSurfaceRef.current?.history(-1); else if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId); @@ -875,7 +823,7 @@ export function PreviewView({ handleRefresh(); return; case "focus-url": - setFocusUrlNonce((value) => (value ?? 0) + 1); + urlInputRef.current?.focus(); return; case "zoom-in": handleZoomIn(); @@ -908,7 +856,7 @@ export function PreviewView({ canGoForward={canGoForward && !serverInputDisabled} refreshDisabled={refreshDisabled || serverInputDisabled} inputDisabled={serverInputDisabled} - focusUrlNonce={focusUrlNonce} + urlInputRef={urlInputRef} onBack={handleBack} onForward={handleForward} onRefresh={handleRefresh} @@ -978,7 +926,12 @@ export function PreviewView({ width={serverToolbarWidth} aspectRatio={serverAspectRatioLocked ? viewport.width / viewport.height : null} onAspectRatioChange={(ratio) => setServerAspectRatioLocked(ratio !== null)} - onChange={(setting) => commitBrowserViewportChange(runtimeTabId, setting)} + onChange={(setting) => + commitBrowserViewportChange( + { threadRef, tabId: snapshot.tabId, runtimeTabId }, + setting, + ) + } /> ) : null} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index c349b692c41e0..b65ab6c189dc0 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -113,7 +113,7 @@ export function subscribeClientSettings(listener: () => void): () => void { }; } -function getClientSettingsHydratedSnapshot(): boolean { +export function getClientSettingsHydratedSnapshot(): boolean { return clientSettingsHydrationStatus === "ready"; } @@ -121,7 +121,7 @@ function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStat return clientSettingsHydrationStatus; } -function subscribeClientSettingsHydration(listener: () => void): () => void { +export function subscribeClientSettingsHydration(listener: () => void): () => void { clientSettingsHydrationListeners.add(listener); void hydrateClientSettings().catch(() => undefined); return () => { diff --git a/apps/web/src/state/previewStream.ts b/apps/web/src/state/previewStream.ts index 3b2cb4629fbb2..52585ee05ccb5 100644 --- a/apps/web/src/state/previewStream.ts +++ b/apps/web/src/state/previewStream.ts @@ -13,6 +13,8 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentSession } from "./session"; +const ACCESS_RETRY_INTERVAL_MS = 10_000; + const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => connectionAtomRuntime .atom((get) => { @@ -25,8 +27,29 @@ const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`preview-stream-access:${environmentId}`)), ); -export function usePreviewStreamAccess(environmentId: EnvironmentId): DeviceHubAccess | null { - const result = useAtomValue(previewStreamAccessAtom(environmentId)); +// Only a surface the user is looking at retries a failed lookup; one-shot readers such as +// picture-in-picture see the failure at once. +const retryingPreviewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const access = previewStreamAccessAtom(environmentId); + const result = get(access); + if (AsyncResult.isFailure(result)) { + const timer = setTimeout(() => get.refresh(access), ACCESS_RETRY_INTERVAL_MS); + get.addFinalizer(() => clearTimeout(timer)); + } + return result; + }), +); + +export function usePreviewStreamAccess( + environmentId: EnvironmentId, + options?: { readonly retry: boolean }, +): DeviceHubAccess | null { + const result = useAtomValue( + options?.retry + ? retryingPreviewStreamAccessAtom(environmentId) + : previewStreamAccessAtom(environmentId), + ); return AsyncResult.isSuccess(result) ? result.value : null; } From ad2d75c1fc0d5d70ffc4739240c5d8f2f31b1da9 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:46:01 -0700 Subject: [PATCH 2/5] fix(web): newest tab's title wins when tabs share a URL Co-Authored-By: Claude Opus 5.5 --- apps/web/src/browserHistoryStore.test.ts | 13 +++++++++++-- apps/web/src/browserHistoryStore.ts | 6 +++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts index d070bbf0a1455..e0f36a749e57b 100644 --- a/apps/web/src/browserHistoryStore.test.ts +++ b/apps/web/src/browserHistoryStore.test.ts @@ -111,11 +111,11 @@ const threadRef = { let settledAt = 0; -function settle(url: string, title: string): void { +function settle(url: string, title: string, tabId = "tab_a"): void { settledAt += 1; const snapshot: PreviewSessionSnapshot = { threadId: threadRef.threadId, - tabId: "tab_a", + tabId, navStatus: { _tag: "Success", url, title }, canGoBack: false, canGoForward: false, @@ -153,6 +153,15 @@ describe("useBrowserHistoryStore", () => { }); }); + it("keeps the newest title when another tab shows the same url", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + settle("http://a.test/", "Old", "tab_a"); + settle("http://a.test/", "Old", "tab_b"); + settle("http://a.test/", "New", "tab_a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New"); + }); + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://a.test/community", 1); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index 9dd33ad013ea1..73fdbf155c71b 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -342,7 +342,11 @@ function recordPreviewTitles(ref: ScopedThreadRef, preview: ThreadPreviewState): if (!projectKey || !state.byProjectKey[projectKey]?.length) return; const connection = readPreparedConnection(ref.environmentId); const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; - for (const session of Object.values(preview.sessions)) { + // Tabs can share a URL; applying oldest first lets the newest settle win. + const sessions = Object.values(preview.sessions).toSorted((a, b) => + a.updatedAt.localeCompare(b.updatedAt), + ); + for (const session of sessions) { const status = session.navStatus; if (status._tag !== "Success" || !status.title) continue; state.setTitleForUrl(projectKey, status.url, status.title, environmentHostname); From d7fc08ff0362e64c97fe33d209497fa4d00aef01 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:27:48 -0700 Subject: [PATCH 3/5] fix(web): an unrelated tab settling no longer replays stale titles Co-Authored-By: Claude Opus 5.5 --- apps/web/src/browserHistoryStore.test.ts | 22 +++++++++++++++++++--- apps/web/src/browserHistoryStore.ts | 17 ++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts index e0f36a749e57b..65fff51326e13 100644 --- a/apps/web/src/browserHistoryStore.test.ts +++ b/apps/web/src/browserHistoryStore.test.ts @@ -111,17 +111,17 @@ const threadRef = { let settledAt = 0; -function settle(url: string, title: string, tabId = "tab_a"): void { +function settle(url: string, title: string, tabId = "tab_a", ref = threadRef): void { settledAt += 1; const snapshot: PreviewSessionSnapshot = { - threadId: threadRef.threadId, + threadId: ref.threadId, tabId, navStatus: { _tag: "Success", url, title }, canGoBack: false, canGoForward: false, updatedAt: new Date(settledAt).toISOString(), }; - applyPreviewServerSnapshot(threadRef, snapshot); + applyPreviewServerSnapshot(ref, snapshot); } function resetStores(): () => void { @@ -162,6 +162,22 @@ describe("useBrowserHistoryStore", () => { expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New"); }); + it("keeps another thread's newer title when an unrelated tab settles", () => { + const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") }; + const store = useBrowserHistoryStore.getState(); + store.registerThreadProject(threadRef, "proj-a"); + store.registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + settle("http://a.test/", "Old", "tab_a", threadRef); + settle("http://a.test/", "New", "tab_a", otherThreadRef); + settle("http://b.test/", "Unrelated", "tab_b", threadRef); + expect( + useBrowserHistoryStore + .getState() + .byProjectKey["proj-a"]?.find((e) => e.url === "http://a.test/")?.title, + ).toBe("New"); + }); + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://a.test/community", 1); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index 73fdbf155c71b..afdef2351668d 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -336,16 +336,22 @@ export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: num * Titles only update entries already in the thread's project history, so they * are applied both when a page settles and when its visit is recorded. */ -function recordPreviewTitles(ref: ScopedThreadRef, preview: ThreadPreviewState): void { +function recordPreviewTitles( + ref: ScopedThreadRef, + preview: ThreadPreviewState, + previousSessions?: ThreadPreviewState["sessions"], +): void { const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; if (!projectKey || !state.byProjectKey[projectKey]?.length) return; const connection = readPreparedConnection(ref.environmentId); const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; // Tabs can share a URL; applying oldest first lets the newest settle win. - const sessions = Object.values(preview.sessions).toSorted((a, b) => - a.updatedAt.localeCompare(b.updatedAt), - ); + // Unchanged sessions are skipped so a stale title cannot overwrite a newer + // one another thread in the same project recorded since. + const sessions = Object.values(preview.sessions) + .filter((session) => previousSessions?.[session.tabId] !== session) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)); for (const session of sessions) { const status = session.navStatus; if (status._tag !== "Success" || !status.title) continue; @@ -365,8 +371,9 @@ export function startPreviewTitleRecording(): () => void { threadKey, appAtomRegistry.subscribe(atom, (next) => { if (next.sessions === previousSessions) return; + const changedSince = previousSessions; previousSessions = next.sessions; - recordPreviewTitles(threadRef, next); + recordPreviewTitles(threadRef, next, changedSince); }), ); } From 3ae6e9975b30be0124c81224ea55649f281d228b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:32:08 -0700 Subject: [PATCH 4/5] fix(web): recording a visit no longer replays the thread's older titles Co-Authored-By: Claude Opus 5.5 --- apps/web/src/browserHistoryStore.test.ts | 16 ++++++++++ apps/web/src/browserHistoryStore.ts | 39 ++++++++++++++++-------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts index 65fff51326e13..743d63579de5a 100644 --- a/apps/web/src/browserHistoryStore.test.ts +++ b/apps/web/src/browserHistoryStore.test.ts @@ -178,6 +178,22 @@ describe("useBrowserHistoryStore", () => { ).toBe("New"); }); + it("keeps another thread's newer title when this thread records a different visit", () => { + const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") }; + const store = useBrowserHistoryStore.getState(); + store.registerThreadProject(threadRef, "proj-a"); + store.registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + settle("http://a.test/", "Old", "tab_a", threadRef); + settle("http://a.test/", "New", "tab_a", otherThreadRef); + recordVisitForThread(threadRef, "http://b.test/", 2); + expect( + useBrowserHistoryStore + .getState() + .byProjectKey["proj-a"]?.find((e) => e.url === "http://a.test/")?.title, + ).toBe("New"); + }); + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://a.test/community", 1); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index afdef2351668d..dd4a5d3993d75 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -1,5 +1,5 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -261,7 +261,10 @@ export const useBrowserHistoryStore = create()( insertOrdered: true, environmentHostname: visit.environmentHostname, }); - recordPreviewTitles(ref, readThreadPreviewState(ref)); + if (pendingVisits) + recordPreviewTitles(ref, readThreadPreviewState(ref), { + urls: pendingVisits.map((visit) => visit.url), + }); }, }), { @@ -329,32 +332,42 @@ export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: num } state.recordVisit(projectKey, url, visitAt, { environmentHostname }); // A page can settle before the address bar records its visit. - recordPreviewTitles(ref, readThreadPreviewState(ref)); + recordPreviewTitles(ref, readThreadPreviewState(ref), { urls: [url] }); } /** - * Titles only update entries already in the thread's project history, so they - * are applied both when a page settles and when its visit is recorded. + * Titles only update entries already in history, so they apply on settle and on + * visit. Callers pass only what just happened: history is shared across a + * project's threads, so replaying an older settle would clobber a newer title. */ function recordPreviewTitles( ref: ScopedThreadRef, preview: ThreadPreviewState, - previousSessions?: ThreadPreviewState["sessions"], + only: + | { readonly urls: ReadonlyArray } + | { readonly changedSince: ThreadPreviewState["sessions"] }, ): void { const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; if (!projectKey || !state.byProjectKey[projectKey]?.length) return; const connection = readPreparedConnection(ref.environmentId); const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; + const titleKey = (url: string) => { + const normalized = normalizeHistoryUrl(url); + return normalized ? titleLookupKey(normalized, environmentHostname) : null; + }; + const visitKeys = "urls" in only ? new Set(only.urls.map(titleKey)) : null; + const include = (session: PreviewSessionSnapshot, url: string) => + "changedSince" in only + ? only.changedSince[session.tabId] !== session + : (visitKeys?.has(titleKey(url)) ?? false); // Tabs can share a URL; applying oldest first lets the newest settle win. - // Unchanged sessions are skipped so a stale title cannot overwrite a newer - // one another thread in the same project recorded since. - const sessions = Object.values(preview.sessions) - .filter((session) => previousSessions?.[session.tabId] !== session) - .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)); + const sessions = Object.values(preview.sessions).toSorted((a, b) => + a.updatedAt.localeCompare(b.updatedAt), + ); for (const session of sessions) { const status = session.navStatus; - if (status._tag !== "Success" || !status.title) continue; + if (status._tag !== "Success" || !status.title || !include(session, status.url)) continue; state.setTitleForUrl(projectKey, status.url, status.title, environmentHostname); } } @@ -373,7 +386,7 @@ export function startPreviewTitleRecording(): () => void { if (next.sessions === previousSessions) return; const changedSince = previousSessions; previousSessions = next.sessions; - recordPreviewTitles(threadRef, next, changedSince); + recordPreviewTitles(threadRef, next, { changedSince }); }), ); } From fed0cc54e78b1470affe7360ddaf96e988b6f3f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:10:21 -0700 Subject: [PATCH 5/5] fix(web): revisiting a page no longer restores this thread's older title A visit, or a queued visit draining on registration, now only titles history entries that have none yet. Settled sessions stay retained, so replaying them on a visit overwrote a newer title another thread had set for the same URL. Title changes still come from the settle subscription. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/browserHistoryStore.test.ts | 25 ++++++++++++++++++++++++ apps/web/src/browserHistoryStore.ts | 17 +++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts index 743d63579de5a..554faf086d5d4 100644 --- a/apps/web/src/browserHistoryStore.test.ts +++ b/apps/web/src/browserHistoryStore.test.ts @@ -194,6 +194,18 @@ describe("useBrowserHistoryStore", () => { ).toBe("New"); }); + it("keeps another thread's newer title when this thread revisits the same url", () => { + const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") }; + const store = useBrowserHistoryStore.getState(); + store.registerThreadProject(threadRef, "proj-a"); + store.registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + settle("http://a.test/", "Old", "tab_a", threadRef); + settle("http://a.test/", "New", "tab_a", otherThreadRef); + recordVisitForThread(threadRef, "http://a.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New"); + }); + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://a.test/community", 1); @@ -285,6 +297,19 @@ describe("titles before registration", () => { const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); }); + + it("keeps another thread's title when a queued visit to the same url drains", () => { + const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") }; + recordVisitForThread(threadRef, "http://a.test/", 1); + settle("http://a.test/", "Old", "tab_a", threadRef); + useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(otherThreadRef, "http://a.test/", 2); + settle("http://a.test/", "New", "tab_a", otherThreadRef); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New"); + }); }); describe("mergeBrowserHistoryState", () => { diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index dd4a5d3993d75..33a9b0dc52390 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -337,8 +337,9 @@ export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: num /** * Titles only update entries already in history, so they apply on settle and on - * visit. Callers pass only what just happened: history is shared across a - * project's threads, so replaying an older settle would clobber a newer title. + * visit. History is shared across a project's threads and settled sessions stay + * retained, so a visit only fills untitled entries: replaying this thread's + * older settle would clobber a newer title. Settles apply what just changed. */ function recordPreviewTitles( ref: ScopedThreadRef, @@ -356,7 +357,17 @@ function recordPreviewTitles( const normalized = normalizeHistoryUrl(url); return normalized ? titleLookupKey(normalized, environmentHostname) : null; }; - const visitKeys = "urls" in only ? new Set(only.urls.map(titleKey)) : null; + const titled = new Set( + "urls" in only + ? state.byProjectKey[projectKey]?.flatMap((entry) => + entry.title ? [titleLookupKey(entry.url, environmentHostname)] : [], + ) + : [], + ); + const visitKeys = + "urls" in only + ? new Set(only.urls.map(titleKey).filter((key) => key !== null && !titled.has(key))) + : null; const include = (session: PreviewSessionSnapshot, url: string) => "changedSince" in only ? only.changedSince[session.tabId] !== session