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()