From 0710eecbd6f7cf26328b99640a4b60a46924a2df Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:29:43 +0000 Subject: [PATCH] fix(onboarding): make cache.disposables safe to call after unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A listener holding a raw `cache` reference (e.g. `EventSource.onerror` in `wizardSessionStreamLogic`) can still fire after `logic.unmount()` — a queued error event dispatching after its own cleanup already ran in the same unmount pass. `beforeUnmount` used to null out `cache.disposables`, so that late call threw a `TypeError` instead of scheduling a reconnect, silently killing the wizard's SSE reconnect loop. Swap in a no-op manager instead of `null` on unmount so `add()`/`dispose()` are safe by construction for every consumer of the plugin, rather than requiring each call site to guard for it individually. Generated-By: PostHog Code Task-Id: 1fa832ed-a607-42df-96ff-2612e1cf55ce --- frontend/src/kea-disposables.test.ts | 28 +++++++++++++++++++++++++++- frontend/src/kea-disposables.ts | 14 +++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/frontend/src/kea-disposables.test.ts b/frontend/src/kea-disposables.test.ts index 43c5cbaec21e..027f7b8b0254 100644 --- a/frontend/src/kea-disposables.test.ts +++ b/frontend/src/kea-disposables.test.ts @@ -1,4 +1,4 @@ -import { MakeLogicType, kea, path } from 'kea' +import { MakeLogicType, actions, kea, listeners, path } from 'kea' import { initKeaTests } from '~/test/init' @@ -199,6 +199,32 @@ describe('disposablesPlugin', () => { }) }) + it('a callback holding a raw cache reference can call disposables.add()/dispose() after unmount without throwing', () => { + // Regression: an EventSource's onerror handler closes over `cache` directly (as + // `listeners(({ cache }) => ...)` does) and can still fire after logic.unmount() — e.g. a + // queued error event dispatching after its own cleanup already ran in the same unmount + // pass. beforeUnmount used to null out cache.disposables, so that late call threw a + // TypeError instead of scheduling a reconnect. + type errorLogicType = MakeLogicType<{}, { triggerLateCallback: () => { value: true } }> + let rawCache: Record | undefined + const errorLogic = kea([ + path(['test', 'disposablesPluginErrorTest']), + actions({ triggerLateCallback: true }), + listeners(({ cache }) => ({ + triggerLateCallback: () => { + rawCache = cache + }, + })), + ]) + errorLogic.mount() + errorLogic.actions.triggerLateCallback() + errorLogic.unmount() + + expect(() => rawCache!.disposables.add(makeSetup(), 'late')).not.toThrow() + expect(() => rawCache!.disposables.dispose('late')).not.toThrow() + expect(setupCalls).toBe(0) + }) + it('logic.unmount() disposes all registered disposables (no leak when consumer omits beforeUnmount)', () => { // Pins the contract that supportTicketCounterLogic relies on after // dropping its explicit `beforeUnmount(() => disposables.disposeAll())`. diff --git a/frontend/src/kea-disposables.ts b/frontend/src/kea-disposables.ts index b8b7cc3e6064..166e85c5eeea 100644 --- a/frontend/src/kea-disposables.ts +++ b/frontend/src/kea-disposables.ts @@ -105,6 +105,18 @@ const detachGlobalVisibilityListener = (): void => { } } +// Stand-in for `logic.cache.disposables` after unmount. Listeners can still fire post-unmount +// (e.g. a queued EventSource error event dispatching after `eventSource.close()` already ran in +// the same unmount pass), and without this they'd dereference `null` and throw. `add`/`dispose` +// are no-ops here since there's nothing left to tear down. +const noopDisposablesManager: DisposablesManager = { + add: () => {}, + dispose: () => false, + registry: new Map(), + keyCounter: 0, + logicPath: '', +} + const initializeDisposablesManager = (logic: LogicWithCache): void => { if (logic.cache.disposables) { return @@ -277,7 +289,7 @@ export const disposablesPlugin: KeaPlugin = { typedLogic.cache.disposables.registry.forEach((entry) => { safeCleanup(entry.cleanup, typedLogic.pathString) }) - typedLogic.cache.disposables = null + typedLogic.cache.disposables = noopDisposablesManager // Detach global listener if no more managers detachGlobalVisibilityListener()