From ed6961a768a2bf4d3e26a097d7e0cb97a59e48b9 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Sat, 18 Apr 2026 12:30:00 -0700 Subject: [PATCH 1/5] Enhance replay functionality in analytics page - Introduced `isReplayerStale` function to check the state of the replayer. - Added effects to manage the replayer lifecycle, including pausing and restarting based on its state. - Updated `executeEffects` to utilize the new replayer management logic, ensuring smoother playback and pause operations. - Improved cleanup of replayer instances to prevent memory leaks and ensure proper resource management. This update enhances the reliability and performance of the replay feature in the analytics section. --- CLAUDE.md | 6 +- .../analytics/replays/page-client.tsx | 149 +++++++++++++----- 2 files changed, 110 insertions(+), 45 deletions(-) mode change 100644 => 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index dc54ebb389..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -# Stack Auth - -## Commands -- **Lint**: `pnpm lint` -- **Typecheck**: `pnpm typecheck` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 6305fea065..576b345ea1 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -72,6 +72,17 @@ type ChunkRow = { createdAt: Date, }; +function isReplayerStale(replayer: RrwebReplayer | null | undefined) { + if (!replayer) return true; + const candidate = replayer as any; + const iframe = (candidate.iframe ?? null) as HTMLIFrameElement | null; + const wrapper = (candidate.wrapper ?? null) as HTMLElement | null; + if (!iframe || !iframe.isConnected) return true; + if (!iframe.contentDocument || !iframe.contentWindow) return true; + if (wrapper && !wrapper.isConnected) return true; + return false; +} + type AdminAppWithSessionReplays = ReturnType & { listSessionReplays: (options?: { limit?: number, @@ -463,6 +474,13 @@ function useReplayMachine(initialSettings: ReplaySettings) { export default function PageClient() { const adminApp = useAdminApp() as AdminAppWithSessionReplays; + useEffect(() => { + document.body.classList.add("rr-block"); + return () => { + document.body.classList.remove("rr-block"); + }; + }, []); + // ---- Recording list + filters ---- const [recordings, setRecordings] = useState([]); @@ -684,6 +702,7 @@ export default function PageClient() { root: rootEl, speed: msRef.current.settings.playerSpeed, skipInactive: msRef.current.settings.skipInactivity, + showWarning: false, triggerFocus: false, }); @@ -793,17 +812,72 @@ export default function PageClient() { } }, [msRef]); + const disposeReplayerForTab = useCallback((tabKey: TabKey, options?: { + pause?: boolean, + scheduleReinit?: boolean, + }) => { + const pause = options?.pause ?? true; + const scheduleReinit = options?.scheduleReinit ?? false; + + const replayer = replayerByTabRef.current.get(tabKey) ?? null; + if (replayer) { + if (pause && !isReplayerStale(replayer)) { + try { + replayer.pause(); + } catch { + // ignore + } + } + } + + replayerByTabRef.current.delete(tabKey); + replayerRootByTabRef.current.delete(tabKey); + + const observer = resizeObserverByTabRef.current.get(tabKey); + if (observer) { + observer.disconnect(); + resizeObserverByTabRef.current.delete(tabKey); + } + + if (!scheduleReinit) return; + + pendingInitByTabRef.current.add(tabKey); + const container = containerByTabRef.current.get(tabKey) ?? null; + const hasEvents = (eventsByTabRef.current.get(tabKey)?.length ?? 0) > 0; + if (!container || !container.isConnected || !hasEvents) return; + + runAsynchronously(() => ensureReplayerForTab(tabKey, msRef.current.generation), { noErrorLogging: true }); + }, [ensureReplayerForTab, msRef]); + + const restartReplayerForTab = useCallback((tabKey: TabKey, options?: { + pause?: boolean, + }) => { + disposeReplayerForTab(tabKey, { + pause: options?.pause ?? false, + scheduleReinit: true, + }); + }, [disposeReplayerForTab]); + + const getUsableReplayerForTab = useCallback((tabKey: TabKey) => { + const replayer = replayerByTabRef.current.get(tabKey) ?? null; + if (!replayer) return null; + if (!isReplayerStale(replayer)) return replayer; + + restartReplayerForTab(tabKey); + return null; + }, [restartReplayerForTab]); + // Effect executor — maps machine effects to imperative DOM/rrweb calls. function executeEffects(effects: ReplayEffect[]) { for (const effect of effects) { switch (effect.type) { case "play_replayer": { - const r = replayerByTabRef.current.get(effect.tabKey); + const r = getUsableReplayerForTab(effect.tabKey); if (r) { try { r.play(effect.localOffsetMs); } catch { - // ignore + restartReplayerForTab(effect.tabKey); } } else { // Replayer doesn't exist — try to create it so REPLAYER_READY @@ -814,18 +888,25 @@ export default function PageClient() { break; } case "pause_replayer_at": { - const r = replayerByTabRef.current.get(effect.tabKey); + const r = getUsableReplayerForTab(effect.tabKey); if (r) { try { r.pause(effect.localOffsetMs); } catch { - // ignore + restartReplayerForTab(effect.tabKey); } } break; } case "pause_all": { - for (const r of replayerByTabRef.current.values()) { + for (const [tabKey, r] of [...replayerByTabRef.current.entries()]) { + if (isReplayerStale(r)) { + disposeReplayerForTab(tabKey, { + pause: false, + scheduleReinit: false, + }); + continue; + } try { r.pause(); } catch { @@ -846,7 +927,11 @@ export default function PageClient() { break; } case "set_replayer_speed": { - for (const r of replayerByTabRef.current.values()) { + for (const [tabKey, r] of [...replayerByTabRef.current.entries()]) { + if (isReplayerStale(r)) { + restartReplayerForTab(tabKey); + continue; + } try { r.setConfig({ speed: effect.speed }); } catch { @@ -856,7 +941,11 @@ export default function PageClient() { break; } case "set_replayer_skip_inactive": { - for (const r of replayerByTabRef.current.values()) { + for (const [tabKey, r] of [...replayerByTabRef.current.entries()]) { + if (isReplayerStale(r)) { + restartReplayerForTab(tabKey); + continue; + } try { r.setConfig({ skipInactive: effect.skipInactive }); } catch { @@ -868,7 +957,7 @@ export default function PageClient() { } case "sync_mini_tabs": { const activeKey = msRef.current.activeTabKey; - for (const [tabKey, r] of replayerByTabRef.current.entries()) { + for (const [tabKey] of [...replayerByTabRef.current.entries()]) { if (tabKey === activeKey) continue; const stream = msRef.current.streams.find(s => s.tabKey === tabKey); if (!stream) continue; @@ -877,10 +966,12 @@ export default function PageClient() { stream.firstEventAtMs, effect.globalOffsetMs, ); + const r = getUsableReplayerForTab(tabKey); + if (!r) continue; try { r.pause(localOffset); } catch { - // ignore + restartReplayerForTab(tabKey); } } break; @@ -894,21 +985,10 @@ export default function PageClient() { } case "recreate_replayer": { const tabKey = effect.tabKey; - const r = replayerByTabRef.current.get(tabKey); - if (r) { - try { - r.pause(); - } catch { - // ignore - } - } - replayerByTabRef.current.delete(tabKey); - replayerRootByTabRef.current.delete(tabKey); - const obs = resizeObserverByTabRef.current.get(tabKey); - if (obs) { - obs.disconnect(); - resizeObserverByTabRef.current.delete(tabKey); - } + disposeReplayerForTab(tabKey, { + pause: replayerByTabRef.current.has(tabKey), + scheduleReinit: false, + }); pendingInitByTabRef.current.add(tabKey); runAsynchronously(() => ensureReplayerForTab(tabKey, effect.generation), { noErrorLogging: true }); break; @@ -940,28 +1020,17 @@ export default function PageClient() { const existingRoot = replayerRootByTabRef.current.get(tabKey); if (existingRoot && existingRoot !== el) { - const r = replayerByTabRef.current.get(tabKey); - if (r) { - try { - r.pause(); - } catch { - // ignore - } - replayerByTabRef.current.delete(tabKey); - replayerRootByTabRef.current.delete(tabKey); - } - const obs = resizeObserverByTabRef.current.get(tabKey); - if (obs) { - obs.disconnect(); - resizeObserverByTabRef.current.delete(tabKey); - } + disposeReplayerForTab(tabKey, { + pause: true, + scheduleReinit: false, + }); pendingInitByTabRef.current.add(tabKey); } if (!pendingInitByTabRef.current.has(tabKey)) return; if ((eventsByTabRef.current.get(tabKey)?.length ?? 0) === 0) return; runAsynchronously(() => ensureReplayerForTab(tabKey, msRef.current.generation), { noErrorLogging: true }); - }, [ensureReplayerForTab, msRef]); + }, [disposeReplayerForTab, ensureReplayerForTab, msRef]); // ---- Load chunks and download events ---- From 78ad5aa11565254ba8cd04083a661c41f3103d35 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Tue, 21 Apr 2026 10:41:08 -0700 Subject: [PATCH 2/5] Enhance PageClient: Prevent multiple replayer instances and clear DOM on reinit - Added a check to prevent adding the "rr-block" class if it already exists on the document body. - Introduced a mechanism to clear any leftover DOM elements from the previous replayer instance to avoid stacking issues. - Improved handling of replayer root references to ensure proper cleanup and initialization. --- .../projects/[projectId]/analytics/replays/page-client.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 576b345ea1..ed9b230f63 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -475,6 +475,7 @@ export default function PageClient() { const adminApp = useAdminApp() as AdminAppWithSessionReplays; useEffect(() => { + if (document.body.classList.contains("rr-block")) return; document.body.classList.add("rr-block"); return () => { document.body.classList.remove("rr-block"); @@ -830,6 +831,7 @@ export default function PageClient() { } } + const root = replayerRootByTabRef.current.get(tabKey) ?? null; replayerByTabRef.current.delete(tabKey); replayerRootByTabRef.current.delete(tabKey); @@ -841,6 +843,10 @@ export default function PageClient() { if (!scheduleReinit) return; + // Clear any DOM left behind by the previous replayer so the new one + // doesn't stack a second iframe/wrapper into the same container. + if (root) root.innerHTML = ""; + pendingInitByTabRef.current.add(tabKey); const container = containerByTabRef.current.get(tabKey) ?? null; const hasEvents = (eventsByTabRef.current.get(tabKey)?.length ?? 0) > 0; From 8bae37be2381ff02567012c1e1d5ef0a6d69c7e0 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Tue, 21 Apr 2026 11:25:01 -0700 Subject: [PATCH 3/5] Fix PageClient error handling: replace ignored exceptions with replayer restart logic. This change ensures that when an error occurs during replayer operations (pause, setConfig), the replayer is restarted for the affected tab, improving stability and user experience. --- .../projects/[projectId]/analytics/replays/page-client.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index ed9b230f63..953aa1802c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -916,7 +916,7 @@ export default function PageClient() { try { r.pause(); } catch { - // ignore + restartReplayerForTab(tabKey); } } break; @@ -941,7 +941,7 @@ export default function PageClient() { try { r.setConfig({ speed: effect.speed }); } catch { - // ignore + restartReplayerForTab(tabKey); } } break; @@ -955,7 +955,7 @@ export default function PageClient() { try { r.setConfig({ skipInactive: effect.skipInactive }); } catch { - // ignore + restartReplayerForTab(tabKey); } } if (!effect.skipInactive) setIsSkipping(false); From 6d634a9e498e3258e25a19ec491ce55040d51f92 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 23 Apr 2026 14:59:07 -0700 Subject: [PATCH 4/5] fix replay replayer lifecycle cleanup --- CLAUDE.md | 6 ++++- .../analytics/replays/page-client.tsx | 26 +++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) mode change 120000 => 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d86..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..dc54ebb389 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Stack Auth + +## Commands +- **Lint**: `pnpm lint` +- **Typecheck**: `pnpm typecheck` diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 953aa1802c..3a4c521a7d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -72,6 +72,11 @@ type ChunkRow = { createdAt: Date, }; +type SessionReplayEventsResponse = { + chunks: ChunkRow[], + chunkEvents: Array<{ chunkId: string, events: unknown[] }>, +}; + function isReplayerStale(replayer: RrwebReplayer | null | undefined) { if (!replayer) return true; const candidate = replayer as any; @@ -98,10 +103,7 @@ type AdminAppWithSessionReplays = ReturnType & { items: RecordingRow[], nextCursor: string | null, }>, - getSessionReplayEvents: (sessionReplayId: string, options?: { offset?: number, limit?: number }) => Promise<{ - chunks: ChunkRow[], - chunkEvents: Array<{ chunkId: string, events: unknown[] }>, - }>, + getSessionReplayEvents: (sessionReplayId: string, options?: { offset?: number, limit?: number }) => Promise, }; type ReplayFilters = { @@ -1020,9 +1022,17 @@ export default function PageClient() { // ---- Container ref callback ---- const setContainerRefForTab = useCallback((tabKey: TabKey, el: HTMLDivElement | null) => { - containerByTabRef.current.set(tabKey, el); + if (!el) { + containerByTabRef.current.delete(tabKey); + disposeReplayerForTab(tabKey, { + pause: true, + scheduleReinit: false, + }); + pendingInitByTabRef.current.delete(tabKey); + return; + } - if (!el) return; + containerByTabRef.current.set(tabKey, el); const existingRoot = replayerRootByTabRef.current.get(tabKey); if (existingRoot && existingRoot !== el) { @@ -1099,7 +1109,7 @@ export default function PageClient() { try { // Phase 1: Fetch initial batch (fast start). - const initialResponse = await adminApp.getSessionReplayEvents(recordingId, { offset: 0, limit: INITIAL_CHUNK_BATCH }); + const initialResponse: SessionReplayEventsResponse = await adminApp.getSessionReplayEvents(recordingId, { offset: 0, limit: INITIAL_CHUNK_BATCH }); if (msRef.current.generation !== gen) return; const allChunkRows: ChunkRow[] = initialResponse.chunks.map((c) => ({ @@ -1187,7 +1197,7 @@ export default function PageClient() { while (offset < totalChunks) { if (msRef.current.generation !== gen) return; - const batchResponse = await adminApp.getSessionReplayEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); + const batchResponse: SessionReplayEventsResponse = await adminApp.getSessionReplayEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); if (msRef.current.generation !== gen) return; processChunkEvents(batchResponse.chunkEvents, allStreams, chunkIdToTabKey); From 4bf3042d93784542923846bbefe892403a6147d1 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 24 Apr 2026 09:57:56 -0700 Subject: [PATCH 5/5] Refactor instrumentation-client and PageClient for improved session replay handling - Updated instrumentation-client to disable PostHog session recording to prevent conflicts with Sentry's Replay integration. - Removed unused session replay event types and lifecycle management code from PageClient to streamline functionality. - Enhanced error handling in PageClient by directly accessing replayer instances, improving stability during playback operations. --- apps/dashboard/instrumentation-client.ts | 14 +- .../analytics/replays/page-client.tsx | 187 +++++------------- 2 files changed, 59 insertions(+), 142 deletions(-) diff --git a/apps/dashboard/instrumentation-client.ts b/apps/dashboard/instrumentation-client.ts index f8a83551c9..e58f4d8379 100644 --- a/apps/dashboard/instrumentation-client.ts +++ b/apps/dashboard/instrumentation-client.ts @@ -14,12 +14,14 @@ export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; const postHogKey = getPublicEnvVar('NEXT_PUBLIC_POSTHOG_KEY') ?? "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k"; if (postHogKey.length > 5) { posthog.init(postHogKey, { - session_recording: { - maskAllInputs: false, - maskInputOptions: { - password: true, - }, - }, + // We use Sentry's Replay integration below for error debugging. Keep + // PostHog session recording off to avoid loading its lazy recorder, which + // is the source of Sentry issue STACK-SERVER-1NK: + // "Called on script loaded before session recording is available". + // PostHog documents `disable_session_recording: true` as the config-level + // way to prevent automatic web session recording. + // Source: https://posthog.com/docs/session-replay/how-to-control-which-sessions-you-record + disable_session_recording: true, defaults: '2025-11-30', api_host: "/consume", ui_host: "https://eu.i.posthog.com", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 3a4c521a7d..6305fea065 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -72,22 +72,6 @@ type ChunkRow = { createdAt: Date, }; -type SessionReplayEventsResponse = { - chunks: ChunkRow[], - chunkEvents: Array<{ chunkId: string, events: unknown[] }>, -}; - -function isReplayerStale(replayer: RrwebReplayer | null | undefined) { - if (!replayer) return true; - const candidate = replayer as any; - const iframe = (candidate.iframe ?? null) as HTMLIFrameElement | null; - const wrapper = (candidate.wrapper ?? null) as HTMLElement | null; - if (!iframe || !iframe.isConnected) return true; - if (!iframe.contentDocument || !iframe.contentWindow) return true; - if (wrapper && !wrapper.isConnected) return true; - return false; -} - type AdminAppWithSessionReplays = ReturnType & { listSessionReplays: (options?: { limit?: number, @@ -103,7 +87,10 @@ type AdminAppWithSessionReplays = ReturnType & { items: RecordingRow[], nextCursor: string | null, }>, - getSessionReplayEvents: (sessionReplayId: string, options?: { offset?: number, limit?: number }) => Promise, + getSessionReplayEvents: (sessionReplayId: string, options?: { offset?: number, limit?: number }) => Promise<{ + chunks: ChunkRow[], + chunkEvents: Array<{ chunkId: string, events: unknown[] }>, + }>, }; type ReplayFilters = { @@ -476,14 +463,6 @@ function useReplayMachine(initialSettings: ReplaySettings) { export default function PageClient() { const adminApp = useAdminApp() as AdminAppWithSessionReplays; - useEffect(() => { - if (document.body.classList.contains("rr-block")) return; - document.body.classList.add("rr-block"); - return () => { - document.body.classList.remove("rr-block"); - }; - }, []); - // ---- Recording list + filters ---- const [recordings, setRecordings] = useState([]); @@ -705,7 +684,6 @@ export default function PageClient() { root: rootEl, speed: msRef.current.settings.playerSpeed, skipInactive: msRef.current.settings.skipInactivity, - showWarning: false, triggerFocus: false, }); @@ -815,77 +793,17 @@ export default function PageClient() { } }, [msRef]); - const disposeReplayerForTab = useCallback((tabKey: TabKey, options?: { - pause?: boolean, - scheduleReinit?: boolean, - }) => { - const pause = options?.pause ?? true; - const scheduleReinit = options?.scheduleReinit ?? false; - - const replayer = replayerByTabRef.current.get(tabKey) ?? null; - if (replayer) { - if (pause && !isReplayerStale(replayer)) { - try { - replayer.pause(); - } catch { - // ignore - } - } - } - - const root = replayerRootByTabRef.current.get(tabKey) ?? null; - replayerByTabRef.current.delete(tabKey); - replayerRootByTabRef.current.delete(tabKey); - - const observer = resizeObserverByTabRef.current.get(tabKey); - if (observer) { - observer.disconnect(); - resizeObserverByTabRef.current.delete(tabKey); - } - - if (!scheduleReinit) return; - - // Clear any DOM left behind by the previous replayer so the new one - // doesn't stack a second iframe/wrapper into the same container. - if (root) root.innerHTML = ""; - - pendingInitByTabRef.current.add(tabKey); - const container = containerByTabRef.current.get(tabKey) ?? null; - const hasEvents = (eventsByTabRef.current.get(tabKey)?.length ?? 0) > 0; - if (!container || !container.isConnected || !hasEvents) return; - - runAsynchronously(() => ensureReplayerForTab(tabKey, msRef.current.generation), { noErrorLogging: true }); - }, [ensureReplayerForTab, msRef]); - - const restartReplayerForTab = useCallback((tabKey: TabKey, options?: { - pause?: boolean, - }) => { - disposeReplayerForTab(tabKey, { - pause: options?.pause ?? false, - scheduleReinit: true, - }); - }, [disposeReplayerForTab]); - - const getUsableReplayerForTab = useCallback((tabKey: TabKey) => { - const replayer = replayerByTabRef.current.get(tabKey) ?? null; - if (!replayer) return null; - if (!isReplayerStale(replayer)) return replayer; - - restartReplayerForTab(tabKey); - return null; - }, [restartReplayerForTab]); - // Effect executor — maps machine effects to imperative DOM/rrweb calls. function executeEffects(effects: ReplayEffect[]) { for (const effect of effects) { switch (effect.type) { case "play_replayer": { - const r = getUsableReplayerForTab(effect.tabKey); + const r = replayerByTabRef.current.get(effect.tabKey); if (r) { try { r.play(effect.localOffsetMs); } catch { - restartReplayerForTab(effect.tabKey); + // ignore } } else { // Replayer doesn't exist — try to create it so REPLAYER_READY @@ -896,29 +814,22 @@ export default function PageClient() { break; } case "pause_replayer_at": { - const r = getUsableReplayerForTab(effect.tabKey); + const r = replayerByTabRef.current.get(effect.tabKey); if (r) { try { r.pause(effect.localOffsetMs); } catch { - restartReplayerForTab(effect.tabKey); + // ignore } } break; } case "pause_all": { - for (const [tabKey, r] of [...replayerByTabRef.current.entries()]) { - if (isReplayerStale(r)) { - disposeReplayerForTab(tabKey, { - pause: false, - scheduleReinit: false, - }); - continue; - } + for (const r of replayerByTabRef.current.values()) { try { r.pause(); } catch { - restartReplayerForTab(tabKey); + // ignore } } break; @@ -935,29 +846,21 @@ export default function PageClient() { break; } case "set_replayer_speed": { - for (const [tabKey, r] of [...replayerByTabRef.current.entries()]) { - if (isReplayerStale(r)) { - restartReplayerForTab(tabKey); - continue; - } + for (const r of replayerByTabRef.current.values()) { try { r.setConfig({ speed: effect.speed }); } catch { - restartReplayerForTab(tabKey); + // ignore } } break; } case "set_replayer_skip_inactive": { - for (const [tabKey, r] of [...replayerByTabRef.current.entries()]) { - if (isReplayerStale(r)) { - restartReplayerForTab(tabKey); - continue; - } + for (const r of replayerByTabRef.current.values()) { try { r.setConfig({ skipInactive: effect.skipInactive }); } catch { - restartReplayerForTab(tabKey); + // ignore } } if (!effect.skipInactive) setIsSkipping(false); @@ -965,7 +868,7 @@ export default function PageClient() { } case "sync_mini_tabs": { const activeKey = msRef.current.activeTabKey; - for (const [tabKey] of [...replayerByTabRef.current.entries()]) { + for (const [tabKey, r] of replayerByTabRef.current.entries()) { if (tabKey === activeKey) continue; const stream = msRef.current.streams.find(s => s.tabKey === tabKey); if (!stream) continue; @@ -974,12 +877,10 @@ export default function PageClient() { stream.firstEventAtMs, effect.globalOffsetMs, ); - const r = getUsableReplayerForTab(tabKey); - if (!r) continue; try { r.pause(localOffset); } catch { - restartReplayerForTab(tabKey); + // ignore } } break; @@ -993,10 +894,21 @@ export default function PageClient() { } case "recreate_replayer": { const tabKey = effect.tabKey; - disposeReplayerForTab(tabKey, { - pause: replayerByTabRef.current.has(tabKey), - scheduleReinit: false, - }); + const r = replayerByTabRef.current.get(tabKey); + if (r) { + try { + r.pause(); + } catch { + // ignore + } + } + replayerByTabRef.current.delete(tabKey); + replayerRootByTabRef.current.delete(tabKey); + const obs = resizeObserverByTabRef.current.get(tabKey); + if (obs) { + obs.disconnect(); + resizeObserverByTabRef.current.delete(tabKey); + } pendingInitByTabRef.current.add(tabKey); runAsynchronously(() => ensureReplayerForTab(tabKey, effect.generation), { noErrorLogging: true }); break; @@ -1022,31 +934,34 @@ export default function PageClient() { // ---- Container ref callback ---- const setContainerRefForTab = useCallback((tabKey: TabKey, el: HTMLDivElement | null) => { - if (!el) { - containerByTabRef.current.delete(tabKey); - disposeReplayerForTab(tabKey, { - pause: true, - scheduleReinit: false, - }); - pendingInitByTabRef.current.delete(tabKey); - return; - } - containerByTabRef.current.set(tabKey, el); + if (!el) return; + const existingRoot = replayerRootByTabRef.current.get(tabKey); if (existingRoot && existingRoot !== el) { - disposeReplayerForTab(tabKey, { - pause: true, - scheduleReinit: false, - }); + const r = replayerByTabRef.current.get(tabKey); + if (r) { + try { + r.pause(); + } catch { + // ignore + } + replayerByTabRef.current.delete(tabKey); + replayerRootByTabRef.current.delete(tabKey); + } + const obs = resizeObserverByTabRef.current.get(tabKey); + if (obs) { + obs.disconnect(); + resizeObserverByTabRef.current.delete(tabKey); + } pendingInitByTabRef.current.add(tabKey); } if (!pendingInitByTabRef.current.has(tabKey)) return; if ((eventsByTabRef.current.get(tabKey)?.length ?? 0) === 0) return; runAsynchronously(() => ensureReplayerForTab(tabKey, msRef.current.generation), { noErrorLogging: true }); - }, [disposeReplayerForTab, ensureReplayerForTab, msRef]); + }, [ensureReplayerForTab, msRef]); // ---- Load chunks and download events ---- @@ -1109,7 +1024,7 @@ export default function PageClient() { try { // Phase 1: Fetch initial batch (fast start). - const initialResponse: SessionReplayEventsResponse = await adminApp.getSessionReplayEvents(recordingId, { offset: 0, limit: INITIAL_CHUNK_BATCH }); + const initialResponse = await adminApp.getSessionReplayEvents(recordingId, { offset: 0, limit: INITIAL_CHUNK_BATCH }); if (msRef.current.generation !== gen) return; const allChunkRows: ChunkRow[] = initialResponse.chunks.map((c) => ({ @@ -1197,7 +1112,7 @@ export default function PageClient() { while (offset < totalChunks) { if (msRef.current.generation !== gen) return; - const batchResponse: SessionReplayEventsResponse = await adminApp.getSessionReplayEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); + const batchResponse = await adminApp.getSessionReplayEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); if (msRef.current.generation !== gen) return; processChunkEvents(batchResponse.chunkEvents, allStreams, chunkIdToTabKey);