using-kea-disposables — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited using-kea-disposables (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Every kea logic in this repo has cache.disposables injected by the local disposablesPlugin (frontend/src/kea-disposables.ts, registered globally in frontend/src/initKea.ts). Reach for it whenever you create a resource that needs explicit teardown — the plugin runs cleanup on unmount and automatically pauses background work when the tab is hidden.
Do not add a `beforeUnmount` for cleanup. The plugin runs the cleanup function you return from setup automatically when the logic unmounts (and re-runs setup/cleanup around tab visibility changes). If you find yourself writing a beforeUnmount whose only job is to clearInterval / clearTimeout / removeEventListener something registered earlier in the same logic, register that resource through cache.disposables.add(...) instead and delete the beforeUnmount. Reserve beforeUnmount for teardown that _isn't_ a resource you control (e.g. flushing state, persisting to localStorage, calling a third-party dispose()).
setInterval or setTimeout inside afterMount, a listener, or a subscriptionwindow.addEventListener, document.addEventListener, or MediaQueryList.addEventListenercache.<thing> plus a matching beforeUnmount cleanup — convert itcache.disposables.add(
setup, // () => () => void — runs immediately; MUST return a cleanup function
key?, // string — re-adding with the same key disposes the previous one first
options?, // { pauseOnPageHidden?: boolean } — default true: cleanup runs on hide, setup re-runs on show
)Canonical example (frontend/src/layout/navigation/noEventsBannerLogic.ts:14-21):
afterMount(({ actions, cache }) => {
cache.disposables.add(() => {
const pollTimer = window.setInterval(() => {
actions.loadCurrentTeam()
}, POLL_INTERVAL_MS)
return () => clearInterval(pollTimer)
})
}),afterMount.cache.disposables.dispose(key) later to stop it earlypauseOnPageHiddenThe default (true) is correct for almost everything — polling, animation tickers, hover timers. Background tabs stop doing work and resume on focus, which dramatically reduces CPU and network cost.
Opt out ({ pauseOnPageHidden: false }) only when the listener must keep firing while the page is hidden:
storage (writes from another tab), online / offline, message (from web workers, service workers, or other windows)visibilitychange listener itself — the whole point is to observe hide/showNote: popstate cannot fire on a hidden tab (it's user-driven), so pausing on hide is fine — see the toolbar example below.
dispose() to stop earlycache.disposables.dispose('key') tears down one specific resource without unmounting the logic. Use it when a state transition should end the resource — pause/resume a poller, stop a hover-only ticker on mouseleave, close a modal-scoped listener.
Unnamed `setInterval` poller — see the canonical example in The pattern (frontend/src/layout/navigation/noEventsBannerLogic.ts:14-21).
Keyed intervals with `dispose()` on hover-end / pause — frontend/src/lib/components/LiveUserCount/liveUserCountLogic.ts:94-118
setIsHovering: ({ isHovering }) => {
if (isHovering) {
actions.setNow(new Date())
cache.disposables.add(() => {
const intervalId = setInterval(() => actions.setNow(new Date()), 500)
return () => clearInterval(intervalId)
}, 'nowInterval')
} else {
cache.disposables.dispose('nowInterval')
}
},
pauseStream: () => {
cache.disposables.dispose('statsInterval')
},
resumeStream: () => {
actions.pollStats()
cache.disposables.add(() => {
const intervalId = setInterval(() => actions.pollStats(), props.pollIntervalMs ?? 30000)
return () => clearInterval(intervalId)
}, 'statsInterval')
},`setTimeout` with key for spam-replacement — frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts:1837-1846
showSeekIndicator: () => {
// Same key auto-disposes the previous timer when spamming
cache.disposables.add(() => {
const timerId = setTimeout(() => actions.hideSeekIndicator(), 600)
return () => clearTimeout(timerId)
}, 'seekIndicatorTimer')
},Multiple keyed window listeners in one `afterMount` — frontend/src/toolbar/bar/toolbarLogic.ts:655-688
cache.disposables.add(() => {
const clickListener = (e: MouseEvent): void => {
/* ... */
}
window.addEventListener('mousedown', clickListener)
return () => window.removeEventListener('mousedown', clickListener)
}, 'clickListener')
// popstate only fires on user-initiated back/forward, so a hidden tab won't
// generate events — pausing on hide (the default) is fine here. Opt out
// only if you must observe popstates while the tab is in the background.
cache.disposables.add(() => {
const popstateHandler = (): void => actions.maybeSendNavigationMessage()
window.addEventListener('popstate', popstateHandler)
return () => window.removeEventListener('popstate', popstateHandler)
}, 'popstateListener')`visibilitychange` listener with `pauseOnPageHidden: false` — frontend/src/scenes/product-tours/productTourLogic.ts:647-663
openToolbarModal: () => {
cache.disposables.add(
() => {
const handler = (): void => {
if (document.visibilityState === 'hidden') {
actions.handleToolbarTabVisibility()
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
},
'toolbarModalVisibility',
{ pauseOnPageHidden: false }
)
},
closeToolbarModal: () => {
cache.disposables.dispose('toolbarModalVisibility')
},`MediaQueryList` listener in `events(afterMount)` — frontend/src/layout/navigation-3000/themeLogic.ts:108-118
events(({ cache, actions }) => ({
afterMount() {
cache.disposables.add(() => {
const prefersColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)')
const onPrefersColorSchemeChange = (e: MediaQueryListEvent): void =>
actions.syncDarkModePreference(e.matches)
prefersColorSchemeMedia.addEventListener('change', onPrefersColorSchemeChange)
return () => prefersColorSchemeMedia.removeEventListener('change', onPrefersColorSchemeChange)
}, 'prefersColorSchemeListener')
},
})),Bare cache.<thing> + beforeUnmount cleanup is the pattern this plugin replaces. Convert these on sight.
Before (frontend/src/lib/components/HedgehogMode/hedgehogModeLogic.ts:205-215):
afterMount(({ actions, cache }) => {
cache.syncInterval = setInterval(() => actions.syncFromState(), 1000)
}),
beforeUnmount(({ cache }) => {
if (cache.syncInterval) {
clearInterval(cache.syncInterval)
cache.syncInterval = null
}
}),After — note the beforeUnmount block is gone entirely; the cleanup function returned from setup is what the plugin runs on unmount:
afterMount(({ actions, cache }) => {
cache.disposables.add(() => {
const id = setInterval(() => actions.syncFromState(), 1000)
return () => clearInterval(id)
}, 'syncInterval')
}),Other open conversion targets:
frontend/src/scenes/welcome/welcomeDialogLogic.ts:325-345 — bare window.addEventListener('storage', ...) with cache.storageHandler stashed manuallyfrontend/src/scenes/inbox/inboxSceneLogic.ts:260-267 — bare setInterval cleared by hand on every state change~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.