{"_id":"kea-disposables","_rev":"4-4759bc2c3d7ed49e48f6a14059db4ea1","name":"kea-disposables","dist-tags":{"latest":"1.0.0"},"versions":{"0.0.0":{"name":"kea-disposables","version":"0.0.0","keywords":["oidc","trusted-publishing","setup"],"_id":"kea-disposables@0.0.0","maintainers":[{"name":"rafael_posthog","email":"rafael@posthog.com"}],"dist":{"shasum":"9ca549095f723563a4dbe1d7a0f50a774f759b26","tarball":"https://registry.npmjs.org/kea-disposables/-/kea-disposables-0.0.0.tgz","fileCount":2,"integrity":"sha512-GWkGIXfD7li+SLFGjJ2zvQz1pPPgvKMNRDiHySGIx515/L8Zq91eHFFtDEYRWs1iN1XpGjNDKTPDYuebUd/VYQ==","signatures":[{"sig":"MEQCIBxfH9n5mlwDqUaPU5OFBEKr7mxD8ySHGltHXdQArd2mAiBvf+xHwtrUu0jjYGBiWONY3hbIcY3U1qyl7ZuIXtCYDA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":2023},"_npmUser":{"name":"rafael_posthog","email":"rafael@posthog.com"},"deprecated":"placeholder, use 1.0.0","_npmVersion":"10.9.4","description":"OIDC trusted publishing setup package for kea-disposables","directories":{},"_nodeVersion":"22.22.0","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/kea-disposables_0.0.0_1787948224675_0.6980874977136515","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"kea-disposables","version":"1.0.0","keywords":["kea","kea-plugin","disposables","cleanup","teardown","setinterval","polling","visibilitychange","redux","posthog"],"author":{"name":"PostHog","email":"hey@posthog.com"},"license":"MIT","_id":"kea-disposables@1.0.0","maintainers":[{"name":"rafael_posthog","email":"rafael@posthog.com"}],"contributors":[{"name":"Paul D'Ambra","email":"paul@posthog.com"},{"name":"Marius Andra","email":"marius.andra@gmail.com"},{"name":"Julian Bez","email":"julian@posthog.com"},{"name":"Sam Pennington","email":"sam@posthog.com"},{"name":"Rafa Audibert","email":"rafael@posthog.com"}],"homepage":"https://github.com/PostHog/kea-disposables#readme","bugs":{"url":"https://github.com/PostHog/kea-disposables/issues"},"dist":{"shasum":"d066819ae8c98f864b8c9ebbe826c3334dca8872","tarball":"https://registry.npmjs.org/kea-disposables/-/kea-disposables-1.0.0.tgz","fileCount":10,"integrity":"sha512-WFME7l3iT7jOCOicXiU6tzeeoQrOInCBBuPqBT9wkaoX6rSJlZgcOghLbAEgVl1BXi3Cl1UsbYJnuSAQ7rZEVg==","signatures":[{"sig":"MEYCIQCADy/o8a6kZNaavQjZ7SYlAge2IzOMw8wYVN9pSwN37wIhAPeXZSTiSvvXY+EjHt6vAiMU9etESEqi8qFaT4xI2UDF","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/kea-disposables@1.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":94016},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.cts","module":"./dist/index.mjs","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"495a2ee986ca9f20c2b1d942180f65185989c147","scripts":{"lint":"oxlint . && oxfmt --check .","test":"vitest run","build":"tsdown","format":"oxfmt --write .","release":"changeset publish","version":"changeset version","changeset":"changeset","test:dist":"node scripts/smoke.mjs","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:b337059d-7770-4bdb-ae17-0f70d9cee2f0"}},"repository":{"url":"git+https://github.com/PostHog/kea-disposables.git","type":"git"},"_npmVersion":"12.0.2","description":"Kea plugin for automatic resource cleanup. Register timers, listeners and subscriptions with a setup function that returns a cleanup function — teardown runs on unmount, and background work auto-pauses while the tab is hidden.","directories":{},"sideEffects":false,"_nodeVersion":"24.19.0","publishConfig":{"access":"public","provenance":true},"_hasShrinkwrap":false,"devDependencies":{"kea":"^3.1.7","jsdom":"^30.0.1","oxfmt":"^0.64.0","oxlint":"^1.74.0","tsdown":"^0.22.13","vitest":"^4.1.10","publint":"^0.3.21","typescript":"^7.0.2","@types/node":"^26.2.0","@changesets/cli":"^3.0.1","@vitest/coverage-v8":"^4.1.10","@arethetypeswrong/cli":"^0.18.5"},"peerDependencies":{"kea":">= 3"},"_npmOperationalInternal":{"tmp":"tmp/kea-disposables_1.0.0_1787950175043_0.006969773671421153","host":"s3://npm-registry-packages-npm-production"}}},"time":{"created":"2026-08-28T20:17:04.607Z","modified":"2026-09-16T15:37:37.664Z","0.0.0":"2026-08-28T20:17:04.807Z","1.0.0":"2026-08-28T20:49:35.203Z"},"bugs":{"url":"https://github.com/PostHog/kea-disposables/issues"},"author":{"name":"PostHog","email":"hey@posthog.com"},"license":"MIT","homepage":"https://github.com/PostHog/kea-disposables#readme","keywords":["kea","kea-plugin","disposables","cleanup","teardown","setinterval","polling","visibilitychange","redux","posthog"],"repository":{"url":"git+https://github.com/PostHog/kea-disposables.git","type":"git"},"description":"Kea plugin for automatic resource cleanup. Register timers, listeners and subscriptions with a setup function that returns a cleanup function — teardown runs on unmount, and background work auto-pauses while the tab is hidden.","contributors":[{"name":"Paul D'Ambra","email":"paul@posthog.com"},{"name":"Marius Andra","email":"marius.andra@gmail.com"},{"name":"Julian Bez","email":"julian@posthog.com"},{"name":"Sam Pennington","email":"sam@posthog.com"},{"name":"Rafa Audibert","email":"rafael@posthog.com"}],"maintainers":[{"email":"marius.andra@gmail.com","name":"mariusandra"},{"email":"rafael@posthog.com","name":"rafael_posthog"}],"readme":"# kea-disposables\n\n[![npm](https://img.shields.io/npm/v/kea-disposables.svg)](https://www.npmjs.com/package/kea-disposables)\n[![CI](https://github.com/PostHog/kea-disposables/actions/workflows/ci.yml/badge.svg)](https://github.com/PostHog/kea-disposables/actions/workflows/ci.yml)\n[![license](https://img.shields.io/npm/l/kea-disposables.svg)](./LICENSE)\n\nA [Kea](https://keajs.org) plugin for automatic resource cleanup, with smart pause/resume on tab\nvisibility.\n\nRegister a timer, listener or subscription with a setup function that returns a cleanup function —\nthe same shape as `useEffect`. The plugin runs the cleanup when the logic unmounts, so you never\nwrite a `beforeUnmount` whose only job is to `clearInterval`. As a bonus, background work stops\nwhile the tab is hidden and picks back up when the user returns.\n\nExtracted from the [PostHog](https://github.com/PostHog/posthog) monorepo, where it runs across\nseveral hundred logics in production.\n\n## Install\n\n```bash\npnpm add kea-disposables\n# npm install kea-disposables\n# yarn add kea-disposables\n```\n\n`kea` (>= 3, including the v4 prereleases) is a peer dependency — the same range every\nfirst-party kea plugin declares.\n\n## Register the plugin\n\n```typescript\nimport { resetContext } from 'kea'\nimport { disposablesPlugin } from 'kea-disposables'\n\nresetContext({\n  plugins: [disposablesPlugin],\n  createStore: true,\n})\n```\n\nEvery logic mounted from that point on gets a `cache.disposables` manager. There is nothing to add\nper-logic.\n\n## Use it\n\n```typescript\nimport { actions, kea, listeners, path } from 'kea'\n\nconst pollingLogic = kea([\n  path(['scenes', 'pollingLogic']),\n  actions({ startPolling: true, stopPolling: true, poll: true }),\n  listeners(({ actions, cache }) => ({\n    startPolling: () => {\n      cache.disposables.add(() => {\n        // Setup runs immediately…\n        const id = setInterval(() => actions.poll(), 5000)\n        // …and returns the cleanup, exactly like a useEffect.\n        return () => clearInterval(id)\n      }, 'poller')\n    },\n    stopPolling: () => {\n      cache.disposables.dispose('poller')\n    },\n  })),\n])\n```\n\nNo `beforeUnmount` needed: unmounting `pollingLogic` clears the interval.\n\n### Or declare them on the logic\n\nFor a resource whose whole life is the logic's life, the `disposables` logic builder saves you\nwriting an `afterMount` just to call `add`:\n\n```typescript\nimport { kea, path } from 'kea'\nimport { disposables } from 'kea-disposables'\n\nconst myLogic = kea([\n  path(['scenes', 'myLogic']),\n  disposables(({ actions }) => ({\n    poller: () => {\n      const id = setInterval(() => actions.poll(), 5000)\n      return () => clearInterval(id)\n    },\n    crossTabSync: {\n      setup: () => {\n        const handler = () => actions.sync()\n        window.addEventListener('storage', handler)\n        return () => window.removeEventListener('storage', handler)\n      },\n      options: { pauseOnPageHidden: false },\n    },\n  })),\n])\n```\n\nThe keys are ordinary disposable keys, so `dispose('poller')` still stops it early and re-adding\n`'poller'` still replaces it. Anything conditional, or re-armed later, still wants\n`cache.disposables.add(...)` from a listener.\n\n## API\n\n### The manager — `logic.cache.disposables`\n\nEvery mounted logic has one. These are its methods.\n\n#### `add(setup, key?, options?)`\n\n| Argument  | Type                              | Notes                                                                                |\n| --------- | --------------------------------- | ------------------------------------------------------------------------------------ |\n| `setup`   | `() => () => void`                | Runs immediately. **Must** return a cleanup function.                                |\n| `key`     | `string` (optional)               | Re-adding the same key disposes the previous entry first. Auto-generated if omitted. |\n| `options` | `{ pauseOnPageHidden?: boolean }` | Defaults to `{ pauseOnPageHidden: true }`.                                           |\n\nReturns nothing.\n\n#### `dispose(key)`\n\nTears down one resource without unmounting the logic. Returns `true` if something was disposed,\n`false` if the key was unknown (or the logic has already unmounted).\n\n#### `isDisposed`\n\n`true` once the logic has begun its final unmount, or once the kea context it belonged to was\nclosed. See [After unmount](#after-unmount).\n\n### Module exports\n\n#### `disposablesPlugin`\n\nThe plugin object. Pass it to `resetContext({ plugins: [...] })`; that is the only setup step.\n\n#### `disposables(input)`\n\nLogic builder. `input` is a record of key → setup function, or key → `{ setup, options }`; it may\nalso be a function of the logic, so setups can reach `actions`. Registers each entry when the\nlogic mounts.\n\n#### `getDisposables(logic)`\n\nReads the manager off a logic with a real type rather than `any`, for code outside the logic's\nown builders. See [TypeScript](#typescript).\n\n## Choosing a key\n\n- **No key** — fire-and-forget, cleaned up only on unmount. Fine for a one-shot listener registered\n  in `afterMount`.\n- **Named key** — needed when you'll `dispose(key)` later to stop it early, or when the same setup\n  may be re-added and each call should replace the previous one (spam-replacement, e.g. a\n  debounce-ish `setTimeout` re-armed on every keystroke).\n\n```typescript\n// Each call with the same key replaces the previous timer.\nshowSeekIndicator: () => {\n  cache.disposables.add(() => {\n    const id = setTimeout(() => actions.hideSeekIndicator(), 600)\n    return () => clearTimeout(id)\n  }, 'seekIndicatorTimer')\n}\n```\n\n## Pause on hidden tabs\n\nBy default every disposable is torn down when the page becomes hidden and set up again when it\nbecomes visible. For polling, animation tickers and hover timers this is what you want — a\nbackground tab stops burning CPU and network.\n\nThe setup function is re-run on resume, so it must be safe to call more than once. A disposable\nadded _while_ the page is hidden is registered but **not** started; its setup is deferred to the\nnext visibility change. That is deliberate: without it, async work that re-arms its own timer in a\n`finally` would quietly defeat the pause.\n\nOpt out only when the resource must keep firing while hidden:\n\n```typescript\ncache.disposables.add(\n  () => {\n    const handler = () => actions.syncFromOtherTab()\n    window.addEventListener('storage', handler)\n    return () => window.removeEventListener('storage', handler)\n  },\n  'crossTabSync',\n  { pauseOnPageHidden: false },\n)\n```\n\nGood candidates for opting out:\n\n- Events that genuinely fire on a hidden tab — `storage`, `online`/`offline`, `message` from\n  workers or other windows\n- A `visibilitychange` listener of your own — observing hide/show is the whole point\n- Anything the user expects to keep running in the background\n\n`popstate` is _not_ one of them: it only fires on user-initiated navigation, so it can't fire on a\nhidden tab and the default pause is fine.\n\n## After unmount\n\n`add` and `dispose` become no-ops once the logic unmounts, so an async continuation that resumes\nafter teardown can call them plainly — no `?.`, no null check. The manager is never null after\nmount.\n\nUsually such a continuation has to skip more than the disposable, though; dispatching an action or\nreading `values` on a torn-down logic is its own bug. Branch on `isDisposed` for that:\n\n```typescript\n// The teardown aborted this request, so the catch can resume after the unmount.\nif (cache.disposables.isDisposed) {\n  return\n}\nactions.connectionErrored(reason)\n```\n\nThis matters most in a `finally`.\n\nTwo caveats:\n\n- **A logic that mounts again gets a fresh manager.** A continuation left over from the previous\n  life can reach `cache.disposables` and find a live one, where `isDisposed` reads `false` — and\n  disposing a shared key from there tears down the _new_ life's resource. Capture what the\n  continuation needs while the logic is alive when that matters.\n- **Replacing the kea context is handled, but it disposes rather than unmounts.** `resetContext()`\n  drops every logic from the store without unmounting it, so `beforeUnmount` never fires. The\n  plugin hooks `beforeCloseContext` and tears every live manager down there, so cleanups do run\n  and `isDisposed` does flip. What does _not_ happen is the logic's own `beforeUnmount`, so a\n  timer callback that reads `values` should still compare `getContext()` against the context the\n  resource was set up in.\n\n## Errors\n\nA `setup` that throws is logged with the logic path and leaves no entry behind — the call site does\nnot see the exception. A cleanup that throws is logged too, and the remaining cleanups still run.\nIf a setup throws while resuming from a hidden tab, its cleanup is replaced with a no-op so the\nstale one can't run against a resource that was never re-created.\n\n## No DOM?\n\nImporting and mounting works without a `document` (SSR prerender, a node-environment test runner).\nVisibility handling degrades to \"always visible, never paused\" — nothing is deferred and no\nlistener is attached.\n\n## TypeScript\n\nKea types `cache` as `Record<string, any>`, so `cache.disposables.add(...)` already compiles; it\njust isn't checked. Annotate the destructured `cache` to get real completions:\n\n```typescript\nimport type { DisposablesCache } from 'kea-disposables'\n\nlisteners(({ cache }: { cache: DisposablesCache }) => ({\n  // cache.disposables is fully typed here\n}))\n```\n\nFrom outside the logic, `getDisposables(logic)` returns a typed `DisposablesManager`:\n\n```typescript\nimport { getDisposables } from 'kea-disposables'\n\ngetDisposables(myLogic).dispose('poller')\n```\n\nThere is no module augmentation that would type `cache.disposables` globally: kea declares\n`cache` as `Record<string, any>`, and an interface augmentation cannot narrow an already-declared\nproperty. No first-party kea plugin does it either.\n\n`DisposablesManager`, `DisposableOptions`, `DisposablesInput`, `DisposableDefinition`,\n`SetupFunction` and `DisposableFunction` are exported too.\n\n## Contributing\n\nSee [CONTRIBUTING.md](./CONTRIBUTING.md).\n\n## License\n\n[MIT](./LICENSE)\n","readmeFilename":"README.md"}