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