From db9bebefc26df168574a407f17064a2456bd714d Mon Sep 17 00:00:00 2001 From: Marcel Poelker Date: Tue, 11 Aug 2026 19:19:12 -0400 Subject: [PATCH 1/3] fix(replay): abort in-flight list load when playlist logic unmounts loadSessionRecordings read values.filters after the API await resumed but before its breakpoint() guard, so embedded playlists (experiment recordings tab, vision scanner runs) that unmounted mid-request threw "[KEA] Can not find path" and dispatched a spurious failure. Move the breakpoint above the values reads; kea bumps breakpoint counters on beforeUnmount, so the resumed listener now aborts silently. Generated-By: PostHog Desktop Task-Id: 03c64c82-7dfc-4d5f-8fed-3d5de52245f5 --- .../sessionRecordingsPlaylistLogic.test.ts | 31 +++++++++++++++++++ .../sessionRecordingsPlaylistLogic.ts | 6 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts index a3f121d85928..822c2908c6f7 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts @@ -1247,6 +1247,37 @@ describe('sessionRecordingsPlaylistLogic', () => { }) }) + describe('unmounting during an in-flight load', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('abandons the load instead of failing on unmounted values reads', async () => { + let resolveList: (value: unknown) => void = () => {} + const listSpy = jest.spyOn(api.recordings, 'list').mockImplementation( + () => + new Promise((resolve) => { + resolveList = resolve + }) as ReturnType + ) + + const embeddedLogic = sessionRecordingsPlaylistLogic({ logicKey: 'unmount-mid-load' }) + embeddedLogic.mount() + + // afterMount kicks off a load; wait for it to get past the debounce and issue the request + while (listSpy.mock.calls.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + embeddedLogic.unmount() + resolveList({ results: [], has_next: false }) + + await expectLogic(embeddedLogic) + .toFinishAllListeners() + .toNotHaveDispatchedActions(['loadSessionRecordingsFailure']) + }) + }) + describe('convertUniversalFiltersToRecordingsQuery', () => { it('passes the visited_page filter as a recording property', () => { const result = convertUniversalFiltersToRecordingsQuery({ diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index 263938657132..b6b2cc8086b5 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -1003,6 +1003,10 @@ export const sessionRecordingsPlaylistLogic = kea Date: Tue, 11 Aug 2026 19:41:42 -0400 Subject: [PATCH 2/3] chore(replay): fix type error in unmount regression test The `as ReturnType` cast contextually typed the Promise constructor, so its resolve callback no longer matched the `(value: unknown) => void` holder under strict function types (TS2322, caught by CI's Frontend typechecking). Build the promise untyped and cast only at the mock's return position. Generated-By: PostHog Desktop Task-Id: 03c64c82-7dfc-4d5f-8fed-3d5de52245f5 --- .../playlist/sessionRecordingsPlaylistLogic.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts index 822c2908c6f7..56c12c0d39c4 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts @@ -1254,12 +1254,12 @@ describe('sessionRecordingsPlaylistLogic', () => { it('abandons the load instead of failing on unmounted values reads', async () => { let resolveList: (value: unknown) => void = () => {} - const listSpy = jest.spyOn(api.recordings, 'list').mockImplementation( - () => - new Promise((resolve) => { - resolveList = resolve - }) as ReturnType - ) + const pendingList = new Promise((resolve) => { + resolveList = resolve + }) + const listSpy = jest + .spyOn(api.recordings, 'list') + .mockImplementation(() => pendingList as ReturnType) const embeddedLogic = sessionRecordingsPlaylistLogic({ logicKey: 'unmount-mid-load' }) embeddedLogic.mount() From 17686d4a9a26957a4f9d68415861088fbe939d7b Mon Sep 17 00:00:00 2001 From: Marcel Poelker Date: Wed, 12 Aug 2026 16:26:43 -0400 Subject: [PATCH 3/3] fix(replay): keep fetch reporting for superseded list loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture values.filters before the awaits and report the fetch from the captured value, restoring breakpoint() to after the report. Superseded and abandoned fetches keep counting toward the list load-time metrics (no survivorship bias toward fast responses), and the report now carries the filters the request was built from rather than whatever they are when the response lands. Unmount safety is unchanged: the only post-await `values` reads sit behind the breakpoint, and dispatching an action on an unmounted logic is safe — only `values` reads throw. Adds a regression test that fails both if the report goes back to reading live values and if breakpoint() moves back above the report. Generated-By: PostHog Desktop Task-Id: 139ec0b8-42da-4ec0-b68d-d0a0e238992e --- .../sessionRecordingsPlaylistLogic.test.ts | 40 ++++++++++++++++++- .../sessionRecordingsPlaylistLogic.ts | 17 +++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts index 56c12c0d39c4..a50be6aa3eae 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts @@ -1247,7 +1247,7 @@ describe('sessionRecordingsPlaylistLogic', () => { }) }) - describe('unmounting during an in-flight load', () => { + describe('superseding or unmounting an in-flight load', () => { afterEach(() => { jest.restoreAllMocks() }) @@ -1276,6 +1276,44 @@ describe('sessionRecordingsPlaylistLogic', () => { .toFinishAllListeners() .toNotHaveDispatchedActions(['loadSessionRecordingsFailure']) }) + + it('still reports a superseded fetch, with the filters the request was built from', async () => { + let resolveList: (value: unknown) => void = () => {} + const pendingList = new Promise((resolve) => { + resolveList = resolve + }) + const listSpy = jest + .spyOn(api.recordings, 'list') + .mockImplementationOnce(() => pendingList as ReturnType) + .mockImplementation( + () => + Promise.resolve({ results: [], has_next: false } as unknown) as ReturnType< + typeof api.recordings.list + > + ) + + const supersededLogic = sessionRecordingsPlaylistLogic({ logicKey: 'superseded-mid-load' }) + supersededLogic.mount() + + // afterMount kicks off a load; wait for it to get past the debounce and issue the request + while (listSpy.mock.calls.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + // supersede the in-flight load, then let its stale response land + supersededLogic.actions.setFilters({ filter_test_accounts: true }) + resolveList({ results: [], has_next: false }) + + await expectLogic(supersededLogic) + .toDispatchActions([ + (action) => + action.type === supersededLogic.actionTypes.reportRecordingsListFetched && + action.payload.filters.filter_test_accounts !== true, + ]) + .toFinishAllListeners() + + supersededLogic.unmount() + }) }) describe('convertUniversalFiltersToRecordingsQuery', () => { diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index b6b2cc8086b5..fc0c9ba95ed3 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -953,7 +953,11 @@ export const sessionRecordingsPlaylistLogic = kea { - const convertedQuery = convertUniversalFiltersToRecordingsQuery(values.filters) + // Captured before the awaits: `values` reads throw if this logic unmounts + // mid-flight, and the fetch report must carry the filters the request was + // built from, not whatever they are once the response lands. + const filters = values.filters + const convertedQuery = convertUniversalFiltersToRecordingsQuery(filters) const params: RecordingsQuery & { add_events_to_property_queries?: '1' } = { ...convertedQuery, person_uuid: props.personUUID ?? '', @@ -1003,17 +1007,18 @@ export const sessionRecordingsPlaylistLogic = kea