| author | |
| committer | |
| log | b5b769dddfe935650b48473dd5b55f2c099abdd8 |
| tree | 99ff4cbde30c8208d06e299477661eff1f8bd103 |
| parent | 3c7862edd70ef9bd721dee3265897c29675926ad |
- Track preview store revisions per thread
- Ignore stale SWR results while revalidating
- Avoid restoring closed sessions from outdated data3 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"; |
| 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 9 | 9 | ||
| 10 | import { ensureEnvironmentApi } from "~/environmentApi"; | 10 | import { ensureEnvironmentApi } from "~/environmentApi"; |
| 11 | import { readPreviewStateRevision } from "~/previewStateStore"; | ||
| 11 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | 12 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 12 | 13 | ||
| 13 | const PREVIEW_SESSION_STALE_TIME_MS = 5_000; | 14 | const 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 |
| 21 | const previewSessionListAtom = Atom.family((threadKey: string) => | 22 | const 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 | ); |
| 48 | 51 | ||
| 49 | export interface PreviewSessionQueryState { | 52 | export 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"; |
| 6 | 6 | ||
| 7 | import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; | 7 | import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; |
| 8 | import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; | 8 | import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; |
| 9 | import { usePreviewStateStore } from "~/previewStateStore"; | 9 | import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore"; |
| 10 | 10 | ||
| 11 | import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; | 11 | import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; |
| 12 | 12 | ||
| ... | @@ -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); |
| 25 | 25 | ||
| 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; | ||
| 35 | 45 | ||
| 36 | // Server has no sessions — try to recover what the renderer remembers | 46 | // 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 | }); |
| 51 | 51 | ||
| 52 | const revisionByThreadKey = new Map<string, number>(); | ||
| 53 | |||
| 54 | const bumpPreviewStateRevision = (threadKey: string): void => { | ||
| 55 | revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1); | ||
| 56 | }; | ||
| 57 | |||
| 58 | export function readPreviewStateRevision(ref: ScopedThreadRef): number { | ||
| 59 | return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0; | ||
| 60 | } | ||
| 61 | |||
| 52 | export interface PreviewStateStoreState { | 62 | export 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 | }), |