Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion frontend/src/kea-disposables.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MakeLogicType, kea, path } from 'kea'
import { MakeLogicType, actions, kea, listeners, path } from 'kea'

import { initKeaTests } from '~/test/init'

Expand Down Expand Up @@ -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<string, any> | undefined
const errorLogic = kea<errorLogicType>([
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())`.
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/kea-disposables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading