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";...@@ -8,6 +8,7 @@ import * as Option from "effect/Option";
8import { AsyncResult, Atom } from "effect/unstable/reactivity";8import { AsyncResult, Atom } from "effect/unstable/reactivity";
99
10import { ensureEnvironmentApi } from "~/environmentApi";10import { ensureEnvironmentApi } from "~/environmentApi";
11import { readPreviewStateRevision } from "~/previewStateStore";
11import { appAtomRegistry } from "~/rpc/atomRegistry";12import { appAtomRegistry } from "~/rpc/atomRegistry";
1213
13const PREVIEW_SESSION_STALE_TIME_MS = 5_000;14const PREVIEW_SESSION_STALE_TIME_MS = 5_000;
...@@ -21,14 +22,16 @@ class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryErro...@@ -21,14 +22,16 @@ class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryErro
21const previewSessionListAtom = Atom.family((threadKey: string) =>22const previewSessionListAtom = Atom.family((threadKey: string) =>
22 Atom.make(23 Atom.make(
23 Effect.tryPromise({24 Effect.tryPromise({
24 try: () => {25 try: async () => {
25 const threadRef = parseScopedThreadKey(threadKey);26 const threadRef = parseScopedThreadKey(threadKey);
26 if (!threadRef) {27 if (!threadRef) {
27 throw new Error(`Invalid scoped thread key: ${threadKey}`);28 throw new Error(`Invalid scoped thread key: ${threadKey}`);
28 }29 }
29 return ensureEnvironmentApi(threadRef.environmentId).preview.list({30 const revision = readPreviewStateRevision(threadRef);
31 const result = await ensureEnvironmentApi(threadRef.environmentId).preview.list({
30 threadId: threadRef.threadId,32 threadId: threadRef.threadId,
31 });33 });
34 return { result, revision };
32 },35 },
33 catch: (cause) =>36 catch: (cause) =>
34 new PreviewSessionQueryError({37 new PreviewSessionQueryError({
...@@ -47,7 +50,10 @@ const previewSessionListAtom = Atom.family((threadKey: string) =>...@@ -47,7 +50,10 @@ const previewSessionListAtom = Atom.family((threadKey: string) =>
47);50);
4851
49export interface PreviewSessionQueryState {52export interface PreviewSessionQueryState {
50 readonly data: PreviewListResult | null;53 readonly data: {
54 readonly result: PreviewListResult;
55 readonly revision: number;
56 } | null;
51 readonly error: string | null;57 readonly error: string | null;
52 readonly isPending: boolean;58 readonly isPending: boolean;
53}59}
apps/web/src/components/preview/usePreviewSession.ts+15-5
...@@ -6,7 +6,7 @@ import { useEffect } from "react";...@@ -6,7 +6,7 @@ import { useEffect } from "react";
66
7import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi";7import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi";
8import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime";8import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime";
9import { usePreviewStateStore } from "~/previewStateStore";9import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore";
1010
11import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState";11import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState";
1212
...@@ -24,14 +24,24 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void {...@@ -24,14 +24,24 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void {
24 const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent);24 const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent);
2525
26 useEffect(() => {26 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 }
28 const threadIdValue = threadRef.threadId;37 const threadIdValue = threadRef.threadId;
29 let cancelled = false;38 let cancelled = false;
30 if (query.data.sessions.length > 0) {39 if (query.data.result.sessions.length > 0) {
31 for (const snapshot of query.data.sessions) applyServerSnapshot(threadRef, snapshot);40 for (const snapshot of query.data.result.sessions) {
41 applyServerSnapshot(threadRef, snapshot);
42 }
32 return;43 return;
33 }44 }
34 if (query.isPending) return;
3545
36 // Server has no sessions — try to recover what the renderer remembers46 // Server has no sessions — try to recover what the renderer remembers
37 // from before the disconnect.47 // from before the disconnect.
apps/web/src/previewStateStore.ts+14
...@@ -49,6 +49,16 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({...@@ -49,6 +49,16 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
49 recentlySeenUrls: [] as string[],49 recentlySeenUrls: [] as string[],
50});50});
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
52export interface PreviewStateStoreState {62export interface PreviewStateStoreState {
53 byThreadKey: Record<string, ThreadPreviewState>;63 byThreadKey: Record<string, ThreadPreviewState>;
54 applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;64 applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;
...@@ -120,6 +130,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({...@@ -120,6 +130,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
120 applyServerEvent: (ref, event) =>130 applyServerEvent: (ref, event) =>
121 set((state) => {131 set((state) => {
122 const threadKey = scopedThreadKey(ref);132 const threadKey = scopedThreadKey(ref);
133 bumpPreviewStateRevision(threadKey);
123 let nextByThread = state.byThreadKey;134 let nextByThread = state.byThreadKey;
124 switch (event.type) {135 switch (event.type) {
125 case "opened":136 case "opened":
...@@ -177,6 +188,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({...@@ -177,6 +188,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
177 applyServerSnapshot: (ref, snapshot) =>188 applyServerSnapshot: (ref, snapshot) =>
178 set((state) => {189 set((state) => {
179 const threadKey = scopedThreadKey(ref);190 const threadKey = scopedThreadKey(ref);
191 bumpPreviewStateRevision(threadKey);
180 const nextByThread = updateThread(state, threadKey, (current) => {192 const nextByThread = updateThread(state, threadKey, (current) => {
181 if (!snapshot && current.snapshot === null) return current;193 if (!snapshot && current.snapshot === null) return current;
182 if (!snapshot) {194 if (!snapshot) {
...@@ -226,6 +238,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({...@@ -226,6 +238,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
226 removeSession: (ref, tabId) =>238 removeSession: (ref, tabId) =>
227 set((state) => {239 set((state) => {
228 const threadKey = scopedThreadKey(ref);240 const threadKey = scopedThreadKey(ref);
241 bumpPreviewStateRevision(threadKey);
229 return {242 return {
230 byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)),243 byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)),
231 };244 };
...@@ -258,6 +271,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({...@@ -258,6 +271,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
258 removeThread: (ref) =>271 removeThread: (ref) =>
259 set((state) => {272 set((state) => {
260 const threadKey = scopedThreadKey(ref);273 const threadKey = scopedThreadKey(ref);
274 bumpPreviewStateRevision(threadKey);
261 if (!(threadKey in state.byThreadKey)) return state;275 if (!(threadKey in state.byThreadKey)) return state;
262 return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) };276 return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) };
263 }),277 }),