authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 18:27:37-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log3c7862edd70ef9bd721dee3265897c29675926ad
tree943cb66572c475d0427cdc0d507efe3ab94c2cb5
parentd8fe3d72c82cc3cc6a6e9275494f5e5377b058a6

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

8 files changed, 224 insertions(+), 52 deletions(-)

apps/web/src/browser/HostedBrowserWebview.tsx+4-16
......@@ -1,8 +1,8 @@
11"use client";
22
3import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts";
3import type { ScopedThreadRef } from "@t3tools/contracts";
44import { useShallow } from "zustand/react/shallow";
5import { useCallback, useEffect, useRef, useState } from "react";
5import { useCallback, useEffect, useRef } from "react";
66
77import { previewBridge } from "~/components/preview/previewBridge";
88import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
......@@ -10,6 +10,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
1010import { useBrowserRecordingStore } from "./browserRecording";
1111import { useBrowserSurfaceStore } from "./browserSurfaceStore";
1212import { acquireDesktopTab } from "./desktopTabLifetime";
13import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
1314
1415interface ElectronWebview extends HTMLElement {
1516 src: string;
......@@ -31,7 +32,7 @@ export function HostedBrowserWebview(props: {
3132 readonly initialUrl: string | null;
3233}) {
3334 const { threadRef, tabId, initialUrl } = props;
34 const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null);
35 const config = usePreviewWebviewConfig(threadRef.environmentId);
3536 const initialSrcRef = useRef(initialUrl ?? "about:blank");
3637 const webviewRef = useRef<ElectronWebview | null>(null);
3738 const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null));
......@@ -41,19 +42,6 @@ export function HostedBrowserWebview(props: {
4142
4243 useEffect(() => acquireDesktopTab(tabId), [tabId]);
4344
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
5745 const setWebviewRef = useCallback((node: HTMLElement | null) => {
5846 webviewRef.current = node as ElectronWebview | null;
5947 if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true");
apps/web/src/browser/previewWebviewConfigState.ts created+48
......@@ -0,0 +1,48 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";
7
8import { previewBridge } from "~/components/preview/previewBridge";
9
10const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000;
11const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000;
12
13class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{
14 readonly message: string;
15 readonly cause?: unknown;
16}> {}
17
18const 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
43export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";
3import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import { AsyncResult, Atom } from "effect/unstable/reactivity";
9
10import { ensureEnvironmentApi } from "~/environmentApi";
11import { appAtomRegistry } from "~/rpc/atomRegistry";
12
13const PREVIEW_SESSION_STALE_TIME_MS = 5_000;
14const PREVIEW_SESSION_IDLE_TTL_MS = 5 * 60_000;
15
16class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryError")<{
17 readonly message: string;
18 readonly cause?: unknown;
19}> {}
20
21const 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
49export interface PreviewSessionQueryState {
50 readonly data: PreviewListResult | null;
51 readonly error: string | null;
52 readonly isPending: boolean;
53}
54
55export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void {
56 appAtomRegistry.refresh(previewSessionListAtom(scopedThreadKey(threadRef)));
57}
58
59export 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";
44import type { ScopedThreadRef } from "@t3tools/contracts";
55import { useEffect } from "react";
66
7import { ensureEnvironmentApi } from "~/environmentApi";
7import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi";
8import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime";
89import { usePreviewStateStore } from "~/previewStateStore";
910
11import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState";
12
1013/**
1114 * Subscribes to the server's per-thread preview events and replays the
1215 * latest snapshot on mount.
......@@ -16,52 +19,82 @@ import { usePreviewStateStore } from "~/previewStateStore";
1619 * `preview.open` so subsequent events land on a real session.
1720 */
1821export function usePreviewSession(threadRef: ScopedThreadRef): void {
22 const query = usePreviewSessionState(threadRef);
1923 const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot);
2024 const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent);
2125
2226 useEffect(() => {
23 if (typeof window === "undefined") return;
24 const api = ensureEnvironmentApi(threadRef.environmentId);
27 if (!query.data) return;
2528 const threadIdValue = threadRef.threadId;
2629 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 }
2746
47 const api = ensureEnvironmentApi(threadRef.environmentId);
2848 void api.preview
29 .list({ threadId: threadIdValue })
30 .then((result) => {
49 .open({ threadId: threadIdValue, url: recoverableUrl })
50 .then((snapshot) => {
3151 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);
5454 })
5555 .catch(() => undefined);
5656
57 const unsubscribe = api.preview.onEvent((event) => {
58 if (event.threadId !== threadIdValue) return;
59 applyServerEvent(threadRef, event);
60 });
61
6257 return () => {
6358 cancelled = true;
64 unsubscribe();
6559 };
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]);
67100}
apps/web/src/environmentApi.ts+1-1
......@@ -72,7 +72,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
7272 reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never),
7373 clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never),
7474 },
75 onEvent: (callback) => rpcClient.preview.onEvent(callback),
75 onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options),
7676 subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options),
7777 },
7878 };
apps/web/src/previewStateStore.test.ts+25
......@@ -262,6 +262,31 @@ describe("previewStateStore (single-tab)", () => {
262262 expect(state.snapshot).toBeNull();
263263 });
264264
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
265290 it("rememberUrl dedupes and caps at limit", () => {
266291 const store = usePreviewStateStore.getState();
267292 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) => ({
189189 desktopByTabId: {},
190190 };
191191 }
192 const existing = current.sessions[snapshot.tabId];
193 if (existing && existing.updatedAt > snapshot.updatedAt) {
194 return current;
195 }
192196 const recentlySeenUrls =
193197 snapshot && snapshot.navStatus._tag !== "Idle"
194198 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
packages/contracts/src/ipc.ts+4-1
......@@ -1190,7 +1190,10 @@ export interface EnvironmentApi {
11901190 reportOwner: (owner: PreviewAutomationOwner) => Promise<void>;
11911191 clearOwner: (input: { clientId: string }) => Promise<void>;
11921192 };
1193 onEvent: (callback: (event: PreviewEvent) => void) => () => void;
1193 onEvent: (
1194 callback: (event: PreviewEvent) => void,
1195 options?: { onResubscribe?: () => void },
1196 ) => () => void;
11941197 subscribePorts: (
11951198 callback: (servers: DiscoveredLocalServerList) => void,
11961199 options?: { onResubscribe?: () => void },