Diagnostics and Leak Checks
TeamPlay keeps a lot of runtime state on your behalf: cached signal proxies, subscription managers with owners and delayed teardown, root contexts, query materializations, React subscription leases, and the ShareDB connection. Diagnostics let you see all of it and check whether it returns to a clean state. Use them in E2E or integration tests to confirm that navigating away or closing a request model really releases everything.
Diagnostics are off by default and cost (almost) nothing unless you load them. The teamplay main entry ships only the switch: hot paths pay one property read, and a bundle without diagnostics grows by 0.3–0.7 KB gzip. The implementation (collectors, leak checks, instrumentation, trace buffers, FinalizationRegistry counting) lives in teamplay/diagnostics (16–20 KB gzip) and is loaded only where you import it. On Node, teamplay loads it for you.
Enabling
Two steps: load the implementation, then switch it on.
The flags are read when teamplay/diagnostics loads. A bundle that does not include it ignores them, and globalThis.__teamplay__.diagnostics stays undefined.
diagnostics is also exported from teamplay, as the same object. Until the implementation is loaded only isEnabled() (always false) and disable() (a no-op) exist there, and enable() throws an error that names the import. The other methods appear once teamplay/diagnostics is loaded. The function aliases enableDiagnostics(), disableDiagnostics() and isDiagnosticsEnabled() are exported from teamplay/diagnostics only.
While enabled, the same API is available as globalThis.__teamplay__.diagnostics (next to the older __teamplay__.DEBUG counters), so test runners can call it with page.evaluate().
Load it first
Load teamplay/diagnostics before anything imports teamplay. TeamPlay creates its FinalizationRegistries when its modules load, and only registries created after diagnostics loaded are counted. Loaded later, everything else still works (counters, trace, leases, snapshots, leak checks), but snapshot().finalization has no counts for the earlier registries: finalization.exact is false and finalization.untracked names them.
ES import statements run before the code of the module that contains them. So put the (conditional) load in its own module and import that module first:
Browser bundles
Bundlers include the module only when it is imported. To keep it out of production builds, import it only in a debug or E2E build. One way is an entry file used only for that build. Another, for bundlers that understand require() (webpack, Metro), is a build-time constant (webpack DefinePlugin) that turns the require() into dead code:
Metro (React Native, Expo)
Metro does not tree-shake. Every module that is reachable from the entry, including import() targets, ships in the bundle. For production builds Metro folds constants before it collects dependencies, so a require() inside an if whose condition is a build-time constant is dropped when the constant is false. Expo inlines EXPO_PUBLIC_* variables in production builds:
In development builds the variable is read at runtime, so the module is always bundled (and loaded only when the variable is set). Without Expo, use if (__DEV__) require('teamplay/diagnostics'), or a Babel plugin that inlines an environment variable. Avoid import() for this: Metro still adds its target to the bundle. To check a build, search it for untrackedSubscribedDocs, a finding code that only the full implementation contains.
Node
import ... from 'teamplay' resolves to the Node entry (exports condition node). It loads teamplay/diagnostics before the runtime, so TEAMPLAY_DIAGNOSTICS covers the whole process and import { diagnostics } from 'teamplay' (or from 'startupjs') has the full API. Nothing is enabled without the variable. Code that imports TeamPlay's source files directly, as TeamPlay's own tests do, must load teamplay/diagnostics first itself (see packages/teamplay/.mocharc.cjs).
Options
enable(options), the global flag object and the env var (trace, stacks, tracesize=N) take the same options:
Event counters and timings are always collected while diagnostics are on. Anything that hooks record (counters, React leases, observer wrappers, pending unsub() promises) only covers what happened after diagnostics were enabled. Once teamplay/diagnostics is loaded, snapshot() also works while diagnostics are disabled: it reads the subscription managers, root contexts and connection on demand.
Overhead
Bundle size (an app entry importing $, useSub, sub and observer, minified, gzip, compared with the same code with every hook removed): without teamplay/diagnostics, the switch and the guarded hook calls add about 0.3 KB with esbuild and about 0.65 KB with Metro. With it, add about 16 KB (esbuild) or 20 KB (Metro). node scripts/bundle-size.mjs measures the main entry.
Runtime, measured on Node with an in-memory ShareDB backend:
A detailed snapshot() plus checkLeaks() takes about 2 µs per cached signal (≈0.7 s with 350k cached signals). Diagnostics never keep signals, docs or components alive: registries hold ids, strings and WeakRefs only. The test suite checks that finalizers still run with tracing and stacks on.
API
snapshot() options:
Default thresholds: pending destroys and pending unsub() promises older than gcDelay + 2000 ms, uncommitted leases and never-mounted observer wrappers older than 5000 ms, readiness pollers older than 10000 ms.
checkLeaks() accepts the same options plus ignore (finding codes, 'prefix.*' allowed), ignoreCollections, staleSignalHashesThreshold (default 1000) and afterGc. It returns { ok, errors, warnings, findings }; ok means there are no error findings.
Reading a snapshot
Every snapshot has a flat metrics map ('docs.entries': 3, 'connection.docs.total': 40, ...). diff() uses it, so you can store snapshots as JSON and compare them later.
Findings
Every finding carries count and up to limit examples with hashes, ids, roots and ages.
Tracing
With trace: true every lifecycle event is stored as { seq, t, type, key, data, stack? }. Event types:
doc.subscribe,doc.unsubscribe,doc.retain,doc.release,doc.destroy.scheduled,doc.destroy.cancelled,doc.destroy,doc.destroyed,doc.destroyByOwner,doc.reconcile.changed,doc.releaseRootdoc.transport.subscribe|fetch|unsubscribe|unfetch,doc.runtime.destroy|dispose- the same
query.*andaggregation.*events, plusquery.transport.destroy,query.runtime.detach doc.subscribe.slowPathWhileLive:sub()returned a promise although the transport was already liveroot.get,root.close,root.dispose.start,root.dispose.end,fr.finalized,*.fr.ownerFinalized(a finalizer released a collected signal's counts:released, the owner's remainingownerCount, and the counts its live signals hold; the*.fr.releasetiming measures the release)react.lease.create|commit|release|releaseUncommitted,react.adm.create|subscribe|destroy,react.observer.create|destroy,react.readinessPoll.start|end,react.batchReadinessPoll.start|end,reaction.create|dispose
Counters that are not trace events: doc.reconcile.noop (a reconcile that changed nothing on the wire; the entry stays stable), doc.reconcile.joined, doc.subscribe.sync, sub.doc.subscribe / sub.doc.fetch (sub records), sub.unsub.*.
Recipes
Playwright: leak check per test
The app under test must be a build that includes teamplay/diagnostics (see Metro and Browser bundles). The fixture only switches it on:
Growth between two points
Node server
No import is needed: the Node entry of teamplay loads diagnostics, and import { diagnostics } from 'teamplay' is the full API. Request roots and models created with getRootSignal() appear in roots. Owner records that outlive a closed root show up as ownersOfClosedRoots. Expose snapshot() and checkLeaks() through a test-only endpoint to read them from your test runner.