From 3c7862edd70ef9bd721dee3265897c29675926ad Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:27:37 -0700 Subject: [PATCH] Add SWR preview session state and resubscribe handling - Fetch preview sessions through atom-backed SWR state - Recover browser preview sessions after reconnects - Ignore older streamed snapshots when SWR revalidates --- apps/web/src/browser/HostedBrowserWebview.tsx | 20 +--- .../src/browser/previewWebviewConfigState.ts | 48 +++++++++ .../components/preview/previewSessionState.ts | 71 ++++++++++++ .../components/preview/usePreviewSession.ts | 101 ++++++++++++------ apps/web/src/environmentApi.ts | 2 +- apps/web/src/previewStateStore.test.ts | 25 +++++ apps/web/src/previewStateStore.ts | 4 + packages/contracts/src/ipc.ts | 5 +- 8 files changed, 224 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/browser/previewWebviewConfigState.ts create mode 100644 apps/web/src/components/preview/previewSessionState.ts diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index e158d7ebad6c3fba206858614eb13e92487b6176..276a9090af2a3114cfe032f1785e36ea1ad0b2c8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,8 +1,8 @@ "use client"; -import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; @@ -10,6 +10,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useBrowserRecordingStore } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; import { acquireDesktopTab } from "./desktopTabLifetime"; +import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; interface ElectronWebview extends HTMLElement { src: string; @@ -31,7 +32,7 @@ export function HostedBrowserWebview(props: { readonly initialUrl: string | null; }) { const { threadRef, tabId, initialUrl } = props; - const [config, setConfig] = useState(null); + const config = usePreviewWebviewConfig(threadRef.environmentId); const initialSrcRef = useRef(initialUrl ?? "about:blank"); const webviewRef = useRef(null); const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); @@ -41,19 +42,6 @@ export function HostedBrowserWebview(props: { useEffect(() => acquireDesktopTab(tabId), [tabId]); - useEffect(() => { - let cancelled = false; - void previewBridge - ?.getPreviewConfig(threadRef.environmentId) - .then((next) => { - if (!cancelled) setConfig(next); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [threadRef.environmentId]); - const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts new file mode 100644 index 0000000000000000000000000000000000000000..99a8388ec5a77aa54d53305205901f639c49534b --- /dev/null +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -0,0 +1,48 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { previewBridge } from "~/components/preview/previewBridge"; + +const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; +const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; + +class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + Effect.tryPromise({ + try: () => { + if (!previewBridge) { + throw new Error("Desktop preview bridge is unavailable."); + } + return previewBridge.getPreviewConfig(environmentId); + }, + catch: (cause) => + new PreviewWebviewConfigError({ + message: "Could not load desktop preview configuration.", + cause, + }), + }), + ).pipe( + Atom.swr({ + staleTime: PREVIEW_CONFIG_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), + Atom.withLabel(`preview:webview-config:${environmentId}`), + ), +); + +export function usePreviewWebviewConfig( + environmentId: EnvironmentId, +): DesktopPreviewWebviewConfig | null { + const result = useAtomValue(previewWebviewConfigAtom(environmentId)); + return Option.getOrNull(AsyncResult.value(result)); +} diff --git a/apps/web/src/components/preview/previewSessionState.ts b/apps/web/src/components/preview/previewSessionState.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d27a76540e28ea32c99ed990abaa7048c064719 --- /dev/null +++ b/apps/web/src/components/preview/previewSessionState.ts @@ -0,0 +1,71 @@ +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +const PREVIEW_SESSION_STALE_TIME_MS = 5_000; +const PREVIEW_SESSION_IDLE_TTL_MS = 5 * 60_000; + +class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const previewSessionListAtom = Atom.family((threadKey: string) => + Atom.make( + Effect.tryPromise({ + try: () => { + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef) { + throw new Error(`Invalid scoped thread key: ${threadKey}`); + } + return ensureEnvironmentApi(threadRef.environmentId).preview.list({ + threadId: threadRef.threadId, + }); + }, + catch: (cause) => + new PreviewSessionQueryError({ + message: "Could not load preview sessions.", + cause, + }), + }), + ).pipe( + Atom.swr({ + staleTime: PREVIEW_SESSION_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(PREVIEW_SESSION_IDLE_TTL_MS), + Atom.withLabel(`preview:sessions:${threadKey}`), + ), +); + +export interface PreviewSessionQueryState { + readonly data: PreviewListResult | null; + readonly error: string | null; + readonly isPending: boolean; +} + +export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void { + appAtomRegistry.refresh(previewSessionListAtom(scopedThreadKey(threadRef))); +} + +export function usePreviewSessionState(threadRef: ScopedThreadRef): PreviewSessionQueryState { + const result = useAtomValue(previewSessionListAtom(scopedThreadKey(threadRef))); + let error: string | null = null; + if (result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = cause instanceof Error ? cause.message : "Could not load preview sessions."; + } + return { + data: Option.getOrNull(AsyncResult.value(result)), + error, + isPending: result.waiting, + }; +} diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 1a4446bd330acd6d2110985ad584716739ccf115..8e4cd95c93494e91df49c776da8b13d194a316a6 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -4,9 +4,12 @@ import { scopedThreadKey } from "@t3tools/client-runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useEffect } from "react"; -import { ensureEnvironmentApi } from "~/environmentApi"; +import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; +import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; import { usePreviewStateStore } from "~/previewStateStore"; +import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; + /** * Subscribes to the server's per-thread preview events and replays the * latest snapshot on mount. @@ -16,52 +19,82 @@ import { usePreviewStateStore } from "~/previewStateStore"; * `preview.open` so subsequent events land on a real session. */ export function usePreviewSession(threadRef: ScopedThreadRef): void { + const query = usePreviewSessionState(threadRef); const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); useEffect(() => { - if (typeof window === "undefined") return; - const api = ensureEnvironmentApi(threadRef.environmentId); + if (!query.data) return; const threadIdValue = threadRef.threadId; let cancelled = false; + if (query.data.sessions.length > 0) { + for (const snapshot of query.data.sessions) applyServerSnapshot(threadRef, snapshot); + return; + } + if (query.isPending) return; + // Server has no sessions — try to recover what the renderer remembers + // from before the disconnect. + const localSnapshot = + usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; + const recoverableUrl = + localSnapshot && localSnapshot.navStatus._tag !== "Idle" ? localSnapshot.navStatus.url : null; + if (!recoverableUrl) { + applyServerSnapshot(threadRef, null); + return; + } + + const api = ensureEnvironmentApi(threadRef.environmentId); void api.preview - .list({ threadId: threadIdValue }) - .then((result) => { + .open({ threadId: threadIdValue, url: recoverableUrl }) + .then((snapshot) => { if (cancelled) return; - // Pick the most recent session. Server returns sessions sorted by - // `updatedAt` ascending, so the last one is freshest. - const serverSnapshot = result.sessions.at(-1) ?? null; - if (serverSnapshot) { - for (const snapshot of result.sessions) applyServerSnapshot(threadRef, snapshot); - return; - } - // Server has no sessions — try to recover what the renderer - // remembers from before the disconnect. - const localSnapshot = - usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; - const recoverableUrl = - localSnapshot && localSnapshot.navStatus._tag !== "Idle" - ? localSnapshot.navStatus.url - : null; - if (recoverableUrl) { - void api.preview - .open({ threadId: threadIdValue, url: recoverableUrl }) - .catch(() => undefined); - } else { - applyServerSnapshot(threadRef, null); - } + applyServerSnapshot(threadRef, snapshot); + refreshPreviewSessionState(threadRef); }) .catch(() => undefined); - const unsubscribe = api.preview.onEvent((event) => { - if (event.threadId !== threadIdValue) return; - applyServerEvent(threadRef, event); - }); - return () => { cancelled = true; - unsubscribe(); }; - }, [applyServerEvent, applyServerSnapshot, threadRef]); + }, [applyServerSnapshot, query.data, query.isPending, threadRef]); + + useEffect(() => { + if (typeof window === "undefined") return; + let clientIdentity: object | null = null; + let unsubscribeEvents: () => void = () => undefined; + + const attach = () => { + const connection = readEnvironmentConnection(threadRef.environmentId); + const api = readEnvironmentApi(threadRef.environmentId); + const nextIdentity = connection?.client ?? api ?? null; + if (nextIdentity === clientIdentity) return; + + unsubscribeEvents(); + unsubscribeEvents = () => undefined; + clientIdentity = nextIdentity; + if (!api) return; + + refreshPreviewSessionState(threadRef); + unsubscribeEvents = api.preview.onEvent( + (event) => { + if (event.threadId !== threadRef.threadId) return; + applyServerEvent(threadRef, event); + if (event.type === "opened" || event.type === "closed") { + refreshPreviewSessionState(threadRef); + } + }, + { + onResubscribe: () => refreshPreviewSessionState(threadRef), + }, + ); + }; + + const unsubscribeConnections = subscribeEnvironmentConnections(attach); + attach(); + return () => { + unsubscribeConnections(); + unsubscribeEvents(); + }; + }, [applyServerEvent, threadRef]); } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 2c2166d50856f668c0f2b607f8af578a044cde72..4bb3c3654cb639b2dd60a27ba481da89c00c788e 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -72,7 +72,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), }, - onEvent: (callback) => rpcClient.preview.onEvent(callback), + onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options), subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), }, }; diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 3adb954cb9803a2a9797bb08f0c5338416b0118b..b2df246f2469739f09886973160e86757ef5f7ba 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -262,6 +262,31 @@ describe("previewStateStore (single-tab)", () => { expect(state.snapshot).toBeNull(); }); + it("does not replace a streamed snapshot with older SWR data", () => { + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot( + ref, + makeSnapshot({ + navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, + updatedAt: "2026-01-01T00:00:02.000Z", + }), + ); + store.applyServerSnapshot( + ref, + makeSnapshot({ + navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, + updatedAt: "2026-01-01T00:00:01.000Z", + }), + ); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus).toEqual({ + _tag: "Success", + url: "http://localhost:5173/new", + title: "New", + }); + }); + it("rememberUrl dedupes and caps at limit", () => { const store = usePreviewStateStore.getState(); for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index d6cacda3af7f75a7e8f24d02e408277416fd4226..75cb45db474b3cbe870908d20a85fd322613d6dc 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -189,6 +189,10 @@ export const usePreviewStateStore = create()((set) => ({ desktopByTabId: {}, }; } + const existing = current.sessions[snapshot.tabId]; + if (existing && existing.updatedAt > snapshot.updatedAt) { + return current; + } const recentlySeenUrls = snapshot && snapshot.navStatus._tag !== "Idle" ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1739a27e55b64f30fb26eab10adf255346c501e1..463879e9a5e19f48943fc3418d15289cb4370bd6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1190,7 +1190,10 @@ export interface EnvironmentApi { reportOwner: (owner: PreviewAutomationOwner) => Promise; clearOwner: (input: { clientId: string }) => Promise; }; - onEvent: (callback: (event: PreviewEvent) => void) => () => void; + onEvent: ( + callback: (event: PreviewEvent) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; subscribePorts: ( callback: (servers: DiscoveredLocalServerList) => void, options?: { onResubscribe?: () => void }, -- 2.54.0