Skip to content

Commit bc216e4

Browse files
Batuhan1999claude
andauthored
fix(web): closed preview sessions no longer keep syncing for the rest of the session (#24081)
Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 64968b3 commit bc216e4

2 files changed

Lines changed: 179 additions & 9 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
EnvironmentId,
3+
ThreadId,
4+
type PreviewEvent,
5+
type PreviewListResult,
6+
type PreviewSessionSnapshot,
7+
} from "@t3tools/contracts";
8+
import { AsyncResult, Atom } from "effect/reactivity";
9+
import { createElement } from "react";
10+
import { describe, expect, it, vi } from "vite-plus/test";
11+
12+
import { act, fakeTimers, render } from "../../../test/render";
13+
14+
// Only the transport is substituted. The hook, atom registry and preview
15+
// store run unchanged, including idle-TTL node removal.
16+
type ListResult = AsyncResult.AsyncResult<PreviewListResult, never>;
17+
type EventResult = AsyncResult.AsyncResult<PreviewEvent, never>;
18+
const harness = vi.hoisted(() => ({
19+
lists: new Map<string, { requests: number; atom: Atom.Writable<ListResult> }>(),
20+
events: new Map<string, Atom.Writable<EventResult>>(),
21+
}));
22+
23+
vi.mock("~/env", () => ({ isElectron: true }));
24+
25+
vi.mock("~/state/preview", async () => {
26+
const { AsyncResult, Atom } = await import("effect/reactivity");
27+
return {
28+
previewEnvironment: {
29+
list: (target: { input: { threadId: string } }) => {
30+
const key = target.input.threadId;
31+
let entry = harness.lists.get(key);
32+
if (!entry) {
33+
let current: ListResult = AsyncResult.initial();
34+
const created = {
35+
requests: 0,
36+
atom: Atom.writable(
37+
() => {
38+
created.requests += 1;
39+
return AsyncResult.waiting(current);
40+
},
41+
(ctx, value: ListResult) => {
42+
current = value;
43+
ctx.setSelf(value);
44+
},
45+
),
46+
};
47+
entry = created;
48+
harness.lists.set(key, entry);
49+
}
50+
return entry.atom;
51+
},
52+
events: (target: { environmentId: string }) => {
53+
let atom = harness.events.get(target.environmentId);
54+
if (!atom) {
55+
atom = Atom.make<EventResult>(AsyncResult.initial());
56+
harness.events.set(target.environmentId, atom);
57+
}
58+
return atom;
59+
},
60+
},
61+
};
62+
});
63+
64+
import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";
65+
import { AppAtomRegistryProvider, appAtomRegistry } from "~/rpc/atomRegistry";
66+
import { previewEnvironment } from "~/state/preview";
67+
import { useDesktopHostedPreviewSessions } from "./usePreviewSession";
68+
69+
describe("desktop-hosted preview session sync", () => {
70+
it("stops syncing a thread once its last tab closes and resumes when a tab opens again", async () => {
71+
resetPreviewStateForTests();
72+
const ref = {
73+
environmentId: EnvironmentId.make("preview-sync-release"),
74+
threadId: ThreadId.make("thread-sync-release"),
75+
};
76+
previewEnvironment.events({ environmentId: ref.environmentId, input: {} });
77+
previewEnvironment.list({
78+
environmentId: ref.environmentId,
79+
input: { threadId: ref.threadId },
80+
});
81+
const events = harness.events.get(ref.environmentId)!;
82+
const listAtom = harness.lists.get(ref.threadId)!.atom;
83+
const listRequests = () => harness.lists.get(ref.threadId)!.requests;
84+
const isListMounted = () => appAtomRegistry.getNodes().has(listAtom);
85+
const snapshot = (tabId: string): PreviewSessionSnapshot => ({
86+
threadId: ref.threadId,
87+
tabId,
88+
navStatus: { _tag: "Idle" },
89+
canGoBack: false,
90+
canGoForward: false,
91+
updatedAt: "2026-10-02T00:00:00.000Z",
92+
});
93+
let revision = 0;
94+
const emit = (type: "opened" | "closed", tabId: string) =>
95+
act(async () => {
96+
revision += 1;
97+
const base = {
98+
threadId: ref.threadId,
99+
tabId,
100+
serverEpoch: "epoch-1",
101+
revision,
102+
createdAt: "2026-10-02T00:00:00.000Z",
103+
};
104+
appAtomRegistry.set(
105+
events,
106+
AsyncResult.success(
107+
type === "opened" ? { ...base, type, snapshot: snapshot(tabId) } : { ...base, type },
108+
),
109+
);
110+
});
111+
112+
function DesktopSessionSync() {
113+
useDesktopHostedPreviewSessions(ref.environmentId);
114+
return null;
115+
}
116+
// The hook renders nothing and never reads or changes the route.
117+
await using _screen = await render(
118+
createElement(AppAtomRegistryProvider, null, createElement(DesktopSessionSync)),
119+
{ router: false },
120+
);
121+
using _timers = fakeTimers();
122+
123+
await emit("opened", "first");
124+
await emit("opened", "second");
125+
appAtomRegistry.set(
126+
listAtom,
127+
AsyncResult.success({
128+
serverEpoch: "epoch-1",
129+
revision,
130+
sessions: [snapshot("first"), snapshot("second")],
131+
}),
132+
);
133+
expect(listRequests()).toBe(1);
134+
135+
// One tab is still open, so the thread keeps syncing.
136+
await emit("closed", "first");
137+
await act(async () => vi.advanceTimersByTime(5_000));
138+
expect(isListMounted()).toBe(true);
139+
140+
await emit("closed", "second");
141+
await act(async () => vi.advanceTimersByTime(5_000));
142+
expect(readThreadPreviewState(ref).sessions).toEqual({});
143+
expect(isListMounted()).toBe(false);
144+
145+
await emit("opened", "third");
146+
expect(listRequests()).toBe(2);
147+
expect(Object.keys(readThreadPreviewState(ref).sessions)).toEqual(["third"]);
148+
await emit("closed", "third");
149+
expect(readThreadPreviewState(ref).sessions).toEqual({});
150+
resetPreviewStateForTests();
151+
});
152+
});

‎apps/web/src/components/preview/usePreviewSession.ts‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AsyncResult, Atom } from "effect/reactivity";
1010

1111
import {
1212
applyPreviewServerEvent,
13+
previewStateAtom,
1314
readThreadPreviewState,
1415
reconcilePreviewServerSessions,
1516
} from "~/previewStateStore";
@@ -97,24 +98,41 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void {
9798

9899
const inactiveAtom = Atom.make(() => undefined);
99100

100-
// Discover background tabs without listing every thread at startup. Retain each
101-
// sync for the host's lifetime so later events still arrive while chat is absent.
101+
// Discover background tabs without listing every thread at startup. Keep each
102+
// sync while the thread has tabs so later events still arrive while chat is
103+
// absent, then release it so closed previews stop costing a list and a fan-out.
102104
const desktopHostedPreviewSyncAtom = Atom.family((environmentId: EnvironmentId) => {
103105
const eventsAtom = previewEnvironment.events({ environmentId, input: {} });
104106
return Atom.make((get) => {
105-
const syncedThreadIds = new Set<string>();
107+
const releaseByThreadId = new Map<string, () => void>();
108+
get.addFinalizer(() => {
109+
for (const release of releaseByThreadId.values()) release();
110+
});
106111
get.subscribe(
107112
eventsAtom,
108113
(result) => {
109114
if (!AsyncResult.isSuccess(result) || result.value.type === "closed") return;
110115
const threadId = result.value.threadId;
111-
if (syncedThreadIds.has(threadId)) return;
112-
syncedThreadIds.add(threadId);
113-
get.mount(
114-
previewSessionSyncAtom(
115-
scopedThreadKey(scopeThreadRef(environmentId, ThreadId.make(threadId))),
116-
),
116+
if (releaseByThreadId.has(threadId)) return;
117+
const threadKey = scopedThreadKey(scopeThreadRef(environmentId, ThreadId.make(threadId)));
118+
const unmount = get.registry.mount(previewSessionSyncAtom(threadKey));
119+
// The opening event may not be applied yet, so wait until a tab has
120+
// been seen. A pending local close can still be cancelled and restored.
121+
let hadSessions = false;
122+
const unwatch = get.registry.subscribe(
123+
previewStateAtom(threadKey),
124+
(state) => {
125+
if (Object.keys(state.sessions).length > 0) hadSessions = true;
126+
else if (hadSessions && state.suppressedTabIds.size === 0) release();
127+
},
128+
{ immediate: true },
117129
);
130+
function release() {
131+
unwatch();
132+
unmount();
133+
releaseByThreadId.delete(threadId);
134+
}
135+
releaseByThreadId.set(threadId, release);
118136
},
119137
{ immediate: true },
120138
);

0 commit comments

Comments
 (0)