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 @@...@@ -1,8 +1,8 @@
1"use client";1"use client";
22
3import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts";3import type { ScopedThreadRef } from "@t3tools/contracts";
4import { useShallow } from "zustand/react/shallow";4import { useShallow } from "zustand/react/shallow";
5import { useCallback, useEffect, useRef, useState } from "react";5import { useCallback, useEffect, useRef } from "react";
66
7import { previewBridge } from "~/components/preview/previewBridge";7import { previewBridge } from "~/components/preview/previewBridge";
8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
...@@ -10,6 +10,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";...@@ -10,6 +10,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
10import { useBrowserRecordingStore } from "./browserRecording";10import { useBrowserRecordingStore } from "./browserRecording";
11import { useBrowserSurfaceStore } from "./browserSurfaceStore";11import { useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { acquireDesktopTab } from "./desktopTabLifetime";12import { acquireDesktopTab } from "./desktopTabLifetime";
13import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
1314
14interface ElectronWebview extends HTMLElement {15interface ElectronWebview extends HTMLElement {
15 src: string;16 src: string;
...@@ -31,7 +32,7 @@ export function HostedBrowserWebview(props: {...@@ -31,7 +32,7 @@ export function HostedBrowserWebview(props: {
31 readonly initialUrl: string | null;32 readonly initialUrl: string | null;
32}) {33}) {
33 const { threadRef, tabId, initialUrl } = props;34 const { threadRef, tabId, initialUrl } = props;
34 const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null);35 const config = usePreviewWebviewConfig(threadRef.environmentId);
35 const initialSrcRef = useRef(initialUrl ?? "about:blank");36 const initialSrcRef = useRef(initialUrl ?? "about:blank");
36 const webviewRef = useRef<ElectronWebview | null>(null);37 const webviewRef = useRef<ElectronWebview | null>(null);
37 const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null));38 const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null));
...@@ -41,19 +42,6 @@ export function HostedBrowserWebview(props: {...@@ -41,19 +42,6 @@ export function HostedBrowserWebview(props: {
4142
42 useEffect(() => acquireDesktopTab(tabId), [tabId]);43 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
57 const setWebviewRef = useCallback((node: HTMLElement | null) => {45 const setWebviewRef = useCallback((node: HTMLElement | null) => {
58 webviewRef.current = node as ElectronWebview | null;46 webviewRef.current = node as ElectronWebview | null;
59 if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true");47 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";...@@ -4,9 +4,12 @@ import { scopedThreadKey } from "@t3tools/client-runtime";
4import type { ScopedThreadRef } from "@t3tools/contracts";4import type { ScopedThreadRef } from "@t3tools/contracts";
5import { useEffect } from "react";5import { useEffect } from "react";
66
7import { ensureEnvironmentApi } from "~/environmentApi";7import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi";
8import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime";
8import { usePreviewStateStore } from "~/previewStateStore";9import { usePreviewStateStore } from "~/previewStateStore";
910
11import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState";
12
10/**13/**
11 * Subscribes to the server's per-thread preview events and replays the14 * Subscribes to the server's per-thread preview events and replays the
12 * latest snapshot on mount.15 * latest snapshot on mount.
...@@ -16,52 +19,82 @@ import { usePreviewStateStore } from "~/previewStateStore";...@@ -16,52 +19,82 @@ import { usePreviewStateStore } from "~/previewStateStore";
16 * `preview.open` so subsequent events land on a real session.19 * `preview.open` so subsequent events land on a real session.
17 */20 */
18export function usePreviewSession(threadRef: ScopedThreadRef): void {21export function usePreviewSession(threadRef: ScopedThreadRef): void {
22 const query = usePreviewSessionState(threadRef);
19 const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot);23 const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot);
20 const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent);24 const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent);
2125
22 useEffect(() => {26 useEffect(() => {
23 if (typeof window === "undefined") return;27 if (!query.data) return;
24 const api = ensureEnvironmentApi(threadRef.environmentId);
25 const threadIdValue = threadRef.threadId;28 const threadIdValue = threadRef.threadId;
26 let cancelled = false;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 }
2746
47 const api = ensureEnvironmentApi(threadRef.environmentId);
28 void api.preview48 void api.preview
29 .list({ threadId: threadIdValue })49 .open({ threadId: threadIdValue, url: recoverableUrl })
30 .then((result) => {50 .then((snapshot) => {
31 if (cancelled) return;51 if (cancelled) return;
32 // Pick the most recent session. Server returns sessions sorted by52 applyServerSnapshot(threadRef, snapshot);
33 // `updatedAt` ascending, so the last one is freshest.53 refreshPreviewSessionState(threadRef);
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 }
54 })54 })
55 .catch(() => undefined);55 .catch(() => undefined);
5656
57 const unsubscribe = api.preview.onEvent((event) => {
58 if (event.threadId !== threadIdValue) return;
59 applyServerEvent(threadRef, event);
60 });
61
62 return () => {57 return () => {
63 cancelled = true;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,7 +72,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
72 reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never),72 reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never),
73 clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never),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 subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options),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,6 +262,31 @@ describe("previewStateStore (single-tab)", () => {
262 expect(state.snapshot).toBeNull();262 expect(state.snapshot).toBeNull();
263 });263 });
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
265 it("rememberUrl dedupes and caps at limit", () => {290 it("rememberUrl dedupes and caps at limit", () => {
266 const store = usePreviewStateStore.getState();291 const store = usePreviewStateStore.getState();
267 for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) {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,6 +189,10 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
189 desktopByTabId: {},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 const recentlySeenUrls =196 const recentlySeenUrls =
193 snapshot && snapshot.navStatus._tag !== "Idle"197 snapshot && snapshot.navStatus._tag !== "Idle"
194 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)198 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
packages/contracts/src/ipc.ts+4-1
...@@ -1190,7 +1190,10 @@ export interface EnvironmentApi {...@@ -1190,7 +1190,10 @@ export interface EnvironmentApi {
1190 reportOwner: (owner: PreviewAutomationOwner) => Promise<void>;1190 reportOwner: (owner: PreviewAutomationOwner) => Promise<void>;
1191 clearOwner: (input: { clientId: string }) => Promise<void>;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 subscribePorts: (1197 subscribePorts: (
1195 callback: (servers: DiscoveredLocalServerList) => void,1198 callback: (servers: DiscoveredLocalServerList) => void,
1196 options?: { onResubscribe?: () => void },1199 options?: { onResubscribe?: () => void },