| author | |
| committer | |
| log | 3c7862edd70ef9bd721dee3265897c29675926ad |
| tree | 943cb66572c475d0427cdc0d507efe3ab94c2cb5 |
| parent | d8fe3d72c82cc3cc6a6e9275494f5e5377b058a6 |
- Fetch preview sessions through atom-backed SWR state
- Recover browser preview sessions after reconnects
- Ignore older streamed snapshots when SWR revalidates8 files changed, 224 insertions(+), 52 deletions(-)
apps/web/src/browser/HostedBrowserWebview.tsx+4-16| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | 4 | import { useShallow } from "zustand/react/shallow"; |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 5 | import { useCallback, useEffect, useRef } from "react"; | |
| 6 | 6 | |
| 7 | 7 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| ... | ... | @@ -10,6 +10,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 10 | 10 | import { useBrowserRecordingStore } from "./browserRecording"; |
| 11 | 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 12 | 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; |
| 13 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; | |
| 13 | 14 | |
| 14 | 15 | interface ElectronWebview extends HTMLElement { |
| 15 | 16 | src: string; |
| ... | ... | @@ -31,7 +32,7 @@ export function HostedBrowserWebview(props: { |
| 31 | 32 | readonly initialUrl: string | null; |
| 32 | 33 | }) { |
| 33 | 34 | const { threadRef, tabId, initialUrl } = props; |
| 34 | const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null); | |
| 35 | const config = usePreviewWebviewConfig(threadRef.environmentId); | |
| 35 | 36 | const initialSrcRef = useRef(initialUrl ?? "about:blank"); |
| 36 | 37 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 37 | 38 | const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); |
| ... | ... | @@ -41,19 +42,6 @@ export function HostedBrowserWebview(props: { |
| 41 | 42 | |
| 42 | 43 | useEffect(() => acquireDesktopTab(tabId), [tabId]); |
| 43 | 44 | |
| 44 | useEffect(() => { | |
| 45 | let cancelled = false; | |
| 46 | void previewBridge | |
| 47 | ?.getPreviewConfig(threadRef.environmentId) | |
| 48 | .then((next) => { | |
| 49 | if (!cancelled) setConfig(next); | |
| 50 | }) | |
| 51 | .catch(() => undefined); | |
| 52 | return () => { | |
| 53 | cancelled = true; | |
| 54 | }; | |
| 55 | }, [threadRef.environmentId]); | |
| 56 | ||
| 57 | 45 | const setWebviewRef = useCallback((node: HTMLElement | null) => { |
| 58 | 46 | webviewRef.current = node as ElectronWebview | null; |
| 59 | 47 | if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); |
apps/web/src/browser/previewWebviewConfigState.ts created+48| ... | ... | @@ -0,0 +1,48 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import * as Data from "effect/Data"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 7 | ||
| 8 | import { previewBridge } from "~/components/preview/previewBridge"; | |
| 9 | ||
| 10 | const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; | |
| 11 | const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; | |
| 12 | ||
| 13 | class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{ | |
| 14 | readonly message: string; | |
| 15 | readonly cause?: unknown; | |
| 16 | }> {} | |
| 17 | ||
| 18 | const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => | |
| 19 | Atom.make( | |
| 20 | Effect.tryPromise({ | |
| 21 | try: () => { | |
| 22 | if (!previewBridge) { | |
| 23 | throw new Error("Desktop preview bridge is unavailable."); | |
| 24 | } | |
| 25 | return previewBridge.getPreviewConfig(environmentId); | |
| 26 | }, | |
| 27 | catch: (cause) => | |
| 28 | new PreviewWebviewConfigError({ | |
| 29 | message: "Could not load desktop preview configuration.", | |
| 30 | cause, | |
| 31 | }), | |
| 32 | }), | |
| 33 | ).pipe( | |
| 34 | Atom.swr({ | |
| 35 | staleTime: PREVIEW_CONFIG_STALE_TIME_MS, | |
| 36 | revalidateOnMount: true, | |
| 37 | }), | |
| 38 | Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), | |
| 39 | Atom.withLabel(`preview:webview-config:${environmentId}`), | |
| 40 | ), | |
| 41 | ); | |
| 42 | ||
| 43 | export function usePreviewWebviewConfig( | |
| 44 | environmentId: EnvironmentId, | |
| 45 | ): DesktopPreviewWebviewConfig | null { | |
| 46 | const result = useAtomValue(previewWebviewConfigAtom(environmentId)); | |
| 47 | return Option.getOrNull(AsyncResult.value(result)); | |
| 48 | } |
apps/web/src/components/preview/previewSessionState.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; | |
| 3 | import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Data from "effect/Data"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 9 | ||
| 10 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 11 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 12 | ||
| 13 | const PREVIEW_SESSION_STALE_TIME_MS = 5_000; | |
| 14 | const PREVIEW_SESSION_IDLE_TTL_MS = 5 * 60_000; | |
| 15 | ||
| 16 | class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryError")<{ | |
| 17 | readonly message: string; | |
| 18 | readonly cause?: unknown; | |
| 19 | }> {} | |
| 20 | ||
| 21 | const previewSessionListAtom = Atom.family((threadKey: string) => | |
| 22 | Atom.make( | |
| 23 | Effect.tryPromise({ | |
| 24 | try: () => { | |
| 25 | const threadRef = parseScopedThreadKey(threadKey); | |
| 26 | if (!threadRef) { | |
| 27 | throw new Error(`Invalid scoped thread key: ${threadKey}`); | |
| 28 | } | |
| 29 | return ensureEnvironmentApi(threadRef.environmentId).preview.list({ | |
| 30 | threadId: threadRef.threadId, | |
| 31 | }); | |
| 32 | }, | |
| 33 | catch: (cause) => | |
| 34 | new PreviewSessionQueryError({ | |
| 35 | message: "Could not load preview sessions.", | |
| 36 | cause, | |
| 37 | }), | |
| 38 | }), | |
| 39 | ).pipe( | |
| 40 | Atom.swr({ | |
| 41 | staleTime: PREVIEW_SESSION_STALE_TIME_MS, | |
| 42 | revalidateOnMount: true, | |
| 43 | }), | |
| 44 | Atom.setIdleTTL(PREVIEW_SESSION_IDLE_TTL_MS), | |
| 45 | Atom.withLabel(`preview:sessions:${threadKey}`), | |
| 46 | ), | |
| 47 | ); | |
| 48 | ||
| 49 | export interface PreviewSessionQueryState { | |
| 50 | readonly data: PreviewListResult | null; | |
| 51 | readonly error: string | null; | |
| 52 | readonly isPending: boolean; | |
| 53 | } | |
| 54 | ||
| 55 | export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void { | |
| 56 | appAtomRegistry.refresh(previewSessionListAtom(scopedThreadKey(threadRef))); | |
| 57 | } | |
| 58 | ||
| 59 | export function usePreviewSessionState(threadRef: ScopedThreadRef): PreviewSessionQueryState { | |
| 60 | const result = useAtomValue(previewSessionListAtom(scopedThreadKey(threadRef))); | |
| 61 | let error: string | null = null; | |
| 62 | if (result._tag === "Failure") { | |
| 63 | const cause = Cause.squash(result.cause); | |
| 64 | error = cause instanceof Error ? cause.message : "Could not load preview sessions."; | |
| 65 | } | |
| 66 | return { | |
| 67 | data: Option.getOrNull(AsyncResult.value(result)), | |
| 68 | error, | |
| 69 | isPending: result.waiting, | |
| 70 | }; | |
| 71 | } |
apps/web/src/components/preview/usePreviewSession.ts+67-34| ... | ... | @@ -4,9 +4,12 @@ import { scopedThreadKey } from "@t3tools/client-runtime"; |
| 4 | 4 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 5 | 5 | import { useEffect } from "react"; |
| 6 | 6 | |
| 7 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 7 | import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; | |
| 8 | import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; | |
| 8 | 9 | import { usePreviewStateStore } from "~/previewStateStore"; |
| 9 | 10 | |
| 11 | import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; | |
| 12 | ||
| 10 | 13 | /** |
| 11 | 14 | * Subscribes to the server's per-thread preview events and replays the |
| 12 | 15 | * latest snapshot on mount. |
| ... | ... | @@ -16,52 +19,82 @@ import { usePreviewStateStore } from "~/previewStateStore"; |
| 16 | 19 | * `preview.open` so subsequent events land on a real session. |
| 17 | 20 | */ |
| 18 | 21 | export function usePreviewSession(threadRef: ScopedThreadRef): void { |
| 22 | const query = usePreviewSessionState(threadRef); | |
| 19 | 23 | const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); |
| 20 | 24 | const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); |
| 21 | 25 | |
| 22 | 26 | useEffect(() => { |
| 23 | if (typeof window === "undefined") return; | |
| 24 | const api = ensureEnvironmentApi(threadRef.environmentId); | |
| 27 | if (!query.data) return; | |
| 25 | 28 | const threadIdValue = threadRef.threadId; |
| 26 | 29 | let cancelled = false; |
| 30 | if (query.data.sessions.length > 0) { | |
| 31 | for (const snapshot of query.data.sessions) applyServerSnapshot(threadRef, snapshot); | |
| 32 | return; | |
| 33 | } | |
| 34 | if (query.isPending) return; | |
| 35 | ||
| 36 | // Server has no sessions — try to recover what the renderer remembers | |
| 37 | // from before the disconnect. | |
| 38 | const localSnapshot = | |
| 39 | usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; | |
| 40 | const recoverableUrl = | |
| 41 | localSnapshot && localSnapshot.navStatus._tag !== "Idle" ? localSnapshot.navStatus.url : null; | |
| 42 | if (!recoverableUrl) { | |
| 43 | applyServerSnapshot(threadRef, null); | |
| 44 | return; | |
| 45 | } | |
| 27 | 46 | |
| 47 | const api = ensureEnvironmentApi(threadRef.environmentId); | |
| 28 | 48 | void api.preview |
| 29 | .list({ threadId: threadIdValue }) | |
| 30 | .then((result) => { | |
| 49 | .open({ threadId: threadIdValue, url: recoverableUrl }) | |
| 50 | .then((snapshot) => { | |
| 31 | 51 | if (cancelled) return; |
| 32 | // Pick the most recent session. Server returns sessions sorted by | |
| 33 | // `updatedAt` ascending, so the last one is freshest. | |
| 34 | const serverSnapshot = result.sessions.at(-1) ?? null; | |
| 35 | if (serverSnapshot) { | |
| 36 | for (const snapshot of result.sessions) applyServerSnapshot(threadRef, snapshot); | |
| 37 | return; | |
| 38 | } | |
| 39 | // Server has no sessions — try to recover what the renderer | |
| 40 | // remembers from before the disconnect. | |
| 41 | const localSnapshot = | |
| 42 | usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; | |
| 43 | const recoverableUrl = | |
| 44 | localSnapshot && localSnapshot.navStatus._tag !== "Idle" | |
| 45 | ? localSnapshot.navStatus.url | |
| 46 | : null; | |
| 47 | if (recoverableUrl) { | |
| 48 | void api.preview | |
| 49 | .open({ threadId: threadIdValue, url: recoverableUrl }) | |
| 50 | .catch(() => undefined); | |
| 51 | } else { | |
| 52 | applyServerSnapshot(threadRef, null); | |
| 53 | } | |
| 52 | applyServerSnapshot(threadRef, snapshot); | |
| 53 | refreshPreviewSessionState(threadRef); | |
| 54 | 54 | }) |
| 55 | 55 | .catch(() => undefined); |
| 56 | 56 | |
| 57 | const unsubscribe = api.preview.onEvent((event) => { | |
| 58 | if (event.threadId !== threadIdValue) return; | |
| 59 | applyServerEvent(threadRef, event); | |
| 60 | }); | |
| 61 | ||
| 62 | 57 | return () => { |
| 63 | 58 | cancelled = true; |
| 64 | unsubscribe(); | |
| 65 | 59 | }; |
| 66 | }, [applyServerEvent, applyServerSnapshot, threadRef]); | |
| 60 | }, [applyServerSnapshot, query.data, query.isPending, threadRef]); | |
| 61 | ||
| 62 | useEffect(() => { | |
| 63 | if (typeof window === "undefined") return; | |
| 64 | let clientIdentity: object | null = null; | |
| 65 | let unsubscribeEvents: () => void = () => undefined; | |
| 66 | ||
| 67 | const attach = () => { | |
| 68 | const connection = readEnvironmentConnection(threadRef.environmentId); | |
| 69 | const api = readEnvironmentApi(threadRef.environmentId); | |
| 70 | const nextIdentity = connection?.client ?? api ?? null; | |
| 71 | if (nextIdentity === clientIdentity) return; | |
| 72 | ||
| 73 | unsubscribeEvents(); | |
| 74 | unsubscribeEvents = () => undefined; | |
| 75 | clientIdentity = nextIdentity; | |
| 76 | if (!api) return; | |
| 77 | ||
| 78 | refreshPreviewSessionState(threadRef); | |
| 79 | unsubscribeEvents = api.preview.onEvent( | |
| 80 | (event) => { | |
| 81 | if (event.threadId !== threadRef.threadId) return; | |
| 82 | applyServerEvent(threadRef, event); | |
| 83 | if (event.type === "opened" || event.type === "closed") { | |
| 84 | refreshPreviewSessionState(threadRef); | |
| 85 | } | |
| 86 | }, | |
| 87 | { | |
| 88 | onResubscribe: () => refreshPreviewSessionState(threadRef), | |
| 89 | }, | |
| 90 | ); | |
| 91 | }; | |
| 92 | ||
| 93 | const unsubscribeConnections = subscribeEnvironmentConnections(attach); | |
| 94 | attach(); | |
| 95 | return () => { | |
| 96 | unsubscribeConnections(); | |
| 97 | unsubscribeEvents(); | |
| 98 | }; | |
| 99 | }, [applyServerEvent, threadRef]); | |
| 67 | 100 | } |
apps/web/src/environmentApi.ts+1-1| ... | ... | @@ -72,7 +72,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 72 | 72 | reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), |
| 73 | 73 | clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), |
| 74 | 74 | }, |
| 75 | onEvent: (callback) => rpcClient.preview.onEvent(callback), | |
| 75 | onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options), | |
| 76 | 76 | subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), |
| 77 | 77 | }, |
| 78 | 78 | }; |
apps/web/src/previewStateStore.test.ts+25| ... | ... | @@ -262,6 +262,31 @@ describe("previewStateStore (single-tab)", () => { |
| 262 | 262 | expect(state.snapshot).toBeNull(); |
| 263 | 263 | }); |
| 264 | 264 | |
| 265 | it("does not replace a streamed snapshot with older SWR data", () => { | |
| 266 | const store = usePreviewStateStore.getState(); | |
| 267 | store.applyServerSnapshot( | |
| 268 | ref, | |
| 269 | makeSnapshot({ | |
| 270 | navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, | |
| 271 | updatedAt: "2026-01-01T00:00:02.000Z", | |
| 272 | }), | |
| 273 | ); | |
| 274 | store.applyServerSnapshot( | |
| 275 | ref, | |
| 276 | makeSnapshot({ | |
| 277 | navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, | |
| 278 | updatedAt: "2026-01-01T00:00:01.000Z", | |
| 279 | }), | |
| 280 | ); | |
| 281 | ||
| 282 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | |
| 283 | expect(state.snapshot?.navStatus).toEqual({ | |
| 284 | _tag: "Success", | |
| 285 | url: "http://localhost:5173/new", | |
| 286 | title: "New", | |
| 287 | }); | |
| 288 | }); | |
| 289 | ||
| 265 | 290 | it("rememberUrl dedupes and caps at limit", () => { |
| 266 | 291 | const store = usePreviewStateStore.getState(); |
| 267 | 292 | for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { |
apps/web/src/previewStateStore.ts+4| ... | ... | @@ -189,6 +189,10 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 189 | 189 | desktopByTabId: {}, |
| 190 | 190 | }; |
| 191 | 191 | } |
| 192 | const existing = current.sessions[snapshot.tabId]; | |
| 193 | if (existing && existing.updatedAt > snapshot.updatedAt) { | |
| 194 | return current; | |
| 195 | } | |
| 192 | 196 | const recentlySeenUrls = |
| 193 | 197 | snapshot && snapshot.navStatus._tag !== "Idle" |
| 194 | 198 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) |
packages/contracts/src/ipc.ts+4-1| ... | ... | @@ -1190,7 +1190,10 @@ export interface EnvironmentApi { |
| 1190 | 1190 | reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; |
| 1191 | 1191 | clearOwner: (input: { clientId: string }) => Promise<void>; |
| 1192 | 1192 | }; |
| 1193 | onEvent: (callback: (event: PreviewEvent) => void) => () => void; | |
| 1193 | onEvent: ( | |
| 1194 | callback: (event: PreviewEvent) => void, | |
| 1195 | options?: { onResubscribe?: () => void }, | |
| 1196 | ) => () => void; | |
| 1194 | 1197 | subscribePorts: ( |
| 1195 | 1198 | callback: (servers: DiscoveredLocalServerList) => void, |
| 1196 | 1199 | options?: { onResubscribe?: () => void }, |