From b5b769dddfe935650b48473dd5b55f2c099abdd8 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:33:37 -0700 Subject: [PATCH] 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 --- .../components/preview/previewSessionState.ts | 12 ++++++++--- .../components/preview/usePreviewSession.ts | 20 ++++++++++++++----- apps/web/src/previewStateStore.ts | 14 +++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/preview/previewSessionState.ts b/apps/web/src/components/preview/previewSessionState.ts index 3d27a76540e28ea32c99ed990abaa7048c064719..0896419571fe33a22b355241b57fc99ecb86cb52 100644 --- a/apps/web/src/components/preview/previewSessionState.ts +++ b/apps/web/src/components/preview/previewSessionState.ts @@ -8,6 +8,7 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { ensureEnvironmentApi } from "~/environmentApi"; +import { readPreviewStateRevision } from "~/previewStateStore"; import { appAtomRegistry } from "~/rpc/atomRegistry"; const PREVIEW_SESSION_STALE_TIME_MS = 5_000; @@ -21,14 +22,16 @@ class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryErro const previewSessionListAtom = Atom.family((threadKey: string) => Atom.make( Effect.tryPromise({ - try: () => { + try: async () => { const threadRef = parseScopedThreadKey(threadKey); if (!threadRef) { throw new Error(`Invalid scoped thread key: ${threadKey}`); } - return ensureEnvironmentApi(threadRef.environmentId).preview.list({ + const revision = readPreviewStateRevision(threadRef); + const result = await ensureEnvironmentApi(threadRef.environmentId).preview.list({ threadId: threadRef.threadId, }); + return { result, revision }; }, catch: (cause) => new PreviewSessionQueryError({ @@ -47,7 +50,10 @@ const previewSessionListAtom = Atom.family((threadKey: string) => ); export interface PreviewSessionQueryState { - readonly data: PreviewListResult | null; + readonly data: { + readonly result: PreviewListResult; + readonly revision: number; + } | null; readonly error: string | null; readonly isPending: boolean; } diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 8e4cd95c93494e91df49c776da8b13d194a316a6..0e24139c9826c9341f52a347766033f2ee218a65 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -6,7 +6,7 @@ import { useEffect } from "react"; import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; -import { usePreviewStateStore } from "~/previewStateStore"; +import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore"; import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; @@ -24,14 +24,24 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void { const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); useEffect(() => { - if (!query.data) return; + // SWR retains stale data while revalidating. Do not project that stale + // snapshot back into the live store because it can resurrect a session + // that was just closed. + if ( + query.isPending || + !query.data || + query.data.revision !== readPreviewStateRevision(threadRef) + ) { + return; + } const threadIdValue = threadRef.threadId; let cancelled = false; - if (query.data.sessions.length > 0) { - for (const snapshot of query.data.sessions) applyServerSnapshot(threadRef, snapshot); + if (query.data.result.sessions.length > 0) { + for (const snapshot of query.data.result.sessions) { + applyServerSnapshot(threadRef, snapshot); + } return; } - if (query.isPending) return; // Server has no sessions — try to recover what the renderer remembers // from before the disconnect. diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 75cb45db474b3cbe870908d20a85fd322613d6dc..ab99ce63bb817168bf90248b5d2f4250689fc256 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -49,6 +49,16 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ recentlySeenUrls: [] as string[], }); +const revisionByThreadKey = new Map(); + +const bumpPreviewStateRevision = (threadKey: string): void => { + revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1); +}; + +export function readPreviewStateRevision(ref: ScopedThreadRef): number { + return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0; +} + export interface PreviewStateStoreState { byThreadKey: Record; applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; @@ -120,6 +130,7 @@ export const usePreviewStateStore = create()((set) => ({ applyServerEvent: (ref, event) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); let nextByThread = state.byThreadKey; switch (event.type) { case "opened": @@ -177,6 +188,7 @@ export const usePreviewStateStore = create()((set) => ({ applyServerSnapshot: (ref, snapshot) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); const nextByThread = updateThread(state, threadKey, (current) => { if (!snapshot && current.snapshot === null) return current; if (!snapshot) { @@ -226,6 +238,7 @@ export const usePreviewStateStore = create()((set) => ({ removeSession: (ref, tabId) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); return { byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), }; @@ -258,6 +271,7 @@ export const usePreviewStateStore = create()((set) => ({ removeThread: (ref) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); if (!(threadKey in state.byThreadKey)) return state; return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; }), -- 2.54.0