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

Prevent stale preview snapshots from resurrecting sessions

- Track preview store revisions per thread - Ignore stale SWR results while revalidating - Avoid restoring closed sessions from outdated data

3 files changed, 38 insertions(+), 8 deletions(-)

apps/web/src/components/preview/previewSessionState.ts+9-3
......@@ -8,6 +8,7 @@ import * as Option from "effect/Option";
88import { AsyncResult, Atom } from "effect/unstable/reactivity";
99
1010import { ensureEnvironmentApi } from "~/environmentApi";
11import { readPreviewStateRevision } from "~/previewStateStore";
1112import { appAtomRegistry } from "~/rpc/atomRegistry";
1213
1314const PREVIEW_SESSION_STALE_TIME_MS = 5_000;
......@@ -21,14 +22,16 @@ class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryErro
2122const previewSessionListAtom = Atom.family((threadKey: string) =>
2223 Atom.make(
2324 Effect.tryPromise({
24 try: () => {
25 try: async () => {
2526 const threadRef = parseScopedThreadKey(threadKey);
2627 if (!threadRef) {
2728 throw new Error(`Invalid scoped thread key: ${threadKey}`);
2829 }
29 return ensureEnvironmentApi(threadRef.environmentId).preview.list({
30 const revision = readPreviewStateRevision(threadRef);
31 const result = await ensureEnvironmentApi(threadRef.environmentId).preview.list({
3032 threadId: threadRef.threadId,
3133 });
34 return { result, revision };
3235 },
3336 catch: (cause) =>
3437 new PreviewSessionQueryError({
......@@ -47,7 +50,10 @@ const previewSessionListAtom = Atom.family((threadKey: string) =>
4750);
4851
4952export interface PreviewSessionQueryState {
50 readonly data: PreviewListResult | null;
53 readonly data: {
54 readonly result: PreviewListResult;
55 readonly revision: number;
56 } | null;
5157 readonly error: string | null;
5258 readonly isPending: boolean;
5359}
apps/web/src/components/preview/usePreviewSession.ts+15-5
......@@ -6,7 +6,7 @@ import { useEffect } from "react";
66
77import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi";
88import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime";
9import { usePreviewStateStore } from "~/previewStateStore";
9import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore";
1010
1111import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState";
1212
......@@ -24,14 +24,24 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void {
2424 const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent);
2525
2626 useEffect(() => {
27 if (!query.data) return;
27 // SWR retains stale data while revalidating. Do not project that stale
28 // snapshot back into the live store because it can resurrect a session
29 // that was just closed.
30 if (
31 query.isPending ||
32 !query.data ||
33 query.data.revision !== readPreviewStateRevision(threadRef)
34 ) {
35 return;
36 }
2837 const threadIdValue = threadRef.threadId;
2938 let cancelled = false;
30 if (query.data.sessions.length > 0) {
31 for (const snapshot of query.data.sessions) applyServerSnapshot(threadRef, snapshot);
39 if (query.data.result.sessions.length > 0) {
40 for (const snapshot of query.data.result.sessions) {
41 applyServerSnapshot(threadRef, snapshot);
42 }
3243 return;
3344 }
34 if (query.isPending) return;
3545
3646 // Server has no sessions — try to recover what the renderer remembers
3747 // from before the disconnect.
apps/web/src/previewStateStore.ts+14
......@@ -49,6 +49,16 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
4949 recentlySeenUrls: [] as string[],
5050});
5151
52const revisionByThreadKey = new Map<string, number>();
53
54const bumpPreviewStateRevision = (threadKey: string): void => {
55 revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1);
56};
57
58export function readPreviewStateRevision(ref: ScopedThreadRef): number {
59 return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0;
60}
61
5262export interface PreviewStateStoreState {
5363 byThreadKey: Record<string, ThreadPreviewState>;
5464 applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;
......@@ -120,6 +130,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
120130 applyServerEvent: (ref, event) =>
121131 set((state) => {
122132 const threadKey = scopedThreadKey(ref);
133 bumpPreviewStateRevision(threadKey);
123134 let nextByThread = state.byThreadKey;
124135 switch (event.type) {
125136 case "opened":
......@@ -177,6 +188,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
177188 applyServerSnapshot: (ref, snapshot) =>
178189 set((state) => {
179190 const threadKey = scopedThreadKey(ref);
191 bumpPreviewStateRevision(threadKey);
180192 const nextByThread = updateThread(state, threadKey, (current) => {
181193 if (!snapshot && current.snapshot === null) return current;
182194 if (!snapshot) {
......@@ -226,6 +238,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
226238 removeSession: (ref, tabId) =>
227239 set((state) => {
228240 const threadKey = scopedThreadKey(ref);
241 bumpPreviewStateRevision(threadKey);
229242 return {
230243 byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)),
231244 };
......@@ -258,6 +271,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
258271 removeThread: (ref) =>
259272 set((state) => {
260273 const threadKey = scopedThreadKey(ref);
274 bumpPreviewStateRevision(threadKey);
261275 if (!(threadKey in state.byThreadKey)) return state;
262276 return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) };
263277 }),