Compare commits

...
Author SHA1 Message Date
Lucas Bordeau dc4b99b76c Ok 2026-03-27 11:24:36 +01:00
Lucas Bordeau fffb7a8057 Ok 2026-03-27 11:17:23 +01:00
Lucas Bordeau 3ec78832ba Ok 2026-03-27 11:14:08 +01:00
19 changed files with 1091 additions and 2 deletions
@@ -0,0 +1,341 @@
# Handoff: State Management Garbage Collector — Task 01
Branch: `feat/state-management-garbage-collector`
Target feature for first full rollout: **Dialog**
---
## Problem
Every atom family in Twenty's Jotai wrapper is backed by a module-level `Map`
cache that never shrinks. Two separate caches leak:
1. **Factory `atomCache`** — closed over inside each `createAtomComponentState`
/ `createAtomComponentFamilyState` call. Holds strong references to atom
config objects. Never cleared.
2. **Jotai store** — holds atom values. Never trimmed. `resetJotaiStore()`
(logout only) recreates the store but does NOT clear factory caches — atom
objects become orphaned.
Scale estimate:
| Scenario | Atoms created | Retention |
|---|---|---|
| 10k-row record table | ~15,000+ | Entire session |
| 10 views browsed | ~150,000 | Never released |
| Long session (8h) | 500k10M+ | Grows monotonically |
| Estimated memory | 100MB1GB+ | — |
---
## The Hard Problem
The GC mechanism itself is the easier half. The harder half: **the entire
codebase assumes atoms are immortal.** Every consumer calls
`useAtomComponentStateValue(someState)` and trusts the value is there and
meaningful. Introducing cleanup creates a new state — "this atom existed, was
populated, and is now back to defaultValue" — that is felt across the whole app.
Concretely: a RecordTable populates ~15 atoms (columns, filters, sorts, row
selection, etc.). If those atoms are silently GC'd and the user comes back, the
component sees `defaultValue` for everything. The re-initialization path must
now be idempotent and always-correct, not a one-shot setup. SSE subscriptions
that push data into those atoms also need to re-establish.
---
## Solutions Explored
### Idea A — Observe first, GC later (instrumentation-first)
Build the observability layer as Phase 1 with zero cleanup. Instrument every
atom with metadata: creation time, last access, subscriber count, access
frequency. Ship a debug panel and send probable-leak warnings (atoms with 0
subscribers for >N minutes, atom caches growing past thresholds) to Sentry.
**Upside:** Zero risk. Produces real-world lifecycle data before any policy
decisions. Surfaces which atoms are ephemeral vs persistent. Also reveals
re-initialization gaps before they become bugs.
**Downside:** Doesn't fix the memory leak. Deferred value.
**Status:** Non-goal for Phase 1, but debug panel and Sentry warnings are
planned as step 4 of the implementation plan (after lifecycle infrastructure is
working).
---
### Idea B — Cold storage / two-tier cache
Instead of deleting atoms, demote them:
```
Active cache (Map<key, atom>) ←→ Cold cache (LRU<key, serialized value>)
```
When all subscribers leave, the atom moves from active to cold. The cold cache
is an LRU with a configurable budget (e.g. 5000 entries / 50MB). The factory
`Map` entry is deleted (freeing the atom object); the value is serialized and
held in the cold cache. On re-mount, if a cold-cached value exists, the new
atom is initialized with it instead of `defaultValue`.
**Upside:** Consumers never see a silent reset — they get their previous state
back. Filters, scroll position, column widths survive eviction. The
re-initialization problem largely disappears for the common case. Memory
pressure is the trigger, not a timer.
**Downside:** Serialization complexity. Not all atom values are serializable
(functions, refs). Would need explicit opt-in or a type-level constraint.
Adds non-trivial infrastructure.
**Status:** Deferred. Recorded as a strong future direction, especially for
atoms with session semantics (RecordTable filters, column widths). Could be
introduced as a per-atom option on `createAtomComponentState`.
---
### Idea C — Automated GC with timer (TanStack Query model)
Track subscriber counts; start a grace-period timer when count reaches 0;
evict the atom when the timer fires.
**Upside:** Fully automatic, no developer burden. Proven pattern (TanStack
Query `gcTime`).
**Downside:** Creates a "navigate away for 5 minutes and lose state" problem.
Requires all consumers to handle the re-initialization case they currently
ignore. Also, the right `gcTime` value is different per atom type.
**Status:** Deferred as a fallback. Tracked in `decisions.md`. Could be used
as a safety net under budget pressure after the explicit lifecycle protocol is
established.
---
### Idea D — Automated scope-level GC under memory pressure
Manage atoms at the feature scope level (arena-style), not individually. A
RecordTable is a scope owning ~50 atoms. A Dropdown is a scope owning ~3.
Dormant scopes retain atoms. Under memory pressure (total atom count exceeds a
budget), dormant scopes are evicted oldest-first (generational: recently dormant
= young, long-dormant = old). If no pressure, scopes live forever — current
behavior is preserved.
**Upside:** No "navigate away and lose state" problem under normal conditions.
Batch-free is efficient. Generalizes across all features without per-feature
implementation.
**Downside:** Still requires re-initialization handling when a scope IS evicted
under pressure. Invisible trigger (memory pressure) is harder to reason about
than explicit lifecycle transitions.
**Status:** Deferred. Strong candidate for Phase 3 after explicit lifecycle is
proven. The feature-scope structure it requires is already emerging from the
chosen design.
---
## Chosen Design: Explicit Lifecycle Protocol
**Core principle:** not fully automated GC, but an explicit lifecycle protocol
that feature implementers hook into. The framework provides lifecycle machinery;
the developer implements handlers because only they know what "cleanup" and
"restoration" mean for their feature.
Analogy: Android's Activity lifecycle (`onCreate`/`onPause`/`onResume`/
`onDestroy`) or Rust's `Drop` trait.
### Lifecycle phases
```
mounted → active → dormant → evicted
↑ │
└──────────┘ (re-mount after eviction = restored)
```
| Phase | Meaning |
|---|---|
| `mounted` | Provider first renders. Atoms created with `defaultValue`. |
| `active` | ≥1 subscriber mounted inside the scope. |
| `dormant` | All subscribers unmounted. Atoms still in memory. |
| `evicted` | Atoms cleared. Scope dead. May be re-mounted. |
Full phase tracking (not just booleans) chosen for granularity — lets
components in a scope observe whether they're in a restoration path vs a
fresh-mount path.
### Developer contract
Feature implementers using a `ComponentInstanceContext` that opts into lifecycle
management provide:
**1) Lifecycle callbacks (imperative, for side effects)**
- `onEvict(instanceId)` — scope about to be cleared. Cancel operations, save
state.
- `onRestore(instanceId)` — scope was evicted and is re-mounting. Re-fetch,
re-subscribe to SSE, restore from cold storage.
**2) Eviction policy (per feature — developer-declared, option C above)**
```ts
useComponentStateContextLifecycle({
context: RecordTableComponentInstanceContext,
instanceId,
evictionPolicy: 'on-dormant', // evict when all subscribers unmount
// or: 'on-memory-pressure' // evict only when budget exceeded
// or: 'manual' // developer calls evict() explicitly
// or: 'never' // opt out (current behavior preserved)
onEvict: handleEvict,
onRestore: handleRestore,
});
```
Rationale for C (developer-declared policy): Dropdown gets `on-dormant` (cheap,
immediate cleanup). RecordTable gets `on-memory-pressure` (keep state unless
tight). Auth state gets `never`. Each feature decides.
Other eviction trigger options (A: fully manual, B: framework-proposes) are
deferred but remain valid and are tracked in `decisions.md`.
**3) Lifecycle state atom (reactive, for conditional rendering)**
```ts
// Exposed per-scope so components inside can observe transitions
componentStateContextLifecyclePhaseComponentState
// Values: 'mounted' | 'active' | 'dormant' | 'evicted'
```
### Gradual rollout
Features without lifecycle handlers keep current behavior (atoms live forever).
The protocol is adopted feature by feature. No big-bang migration.
A lint rule or TypeScript constraint may eventually require lifecycle handlers
when creating a `ComponentInstanceContext`. Making the implicit contract
explicit.
---
## Implementation Plan
### Build order (everything for Dialog first)
1. **Lifecycle infrastructure** ← this PR (Task 01)
2. `useComponentStateContextLifecycle` hook — registers handler, declares
policy, drives phase transitions
3. Debug panel component — atom count, memory estimate, phase visualization,
leak warnings
4. Sentry leak warnings — production-mode leak detection
5. **Dialog adopts the full protocol**`evictionPolicy: 'on-dormant'`,
trivial `onEvict` (clear queue), trivial `onRestore` (noop)
6. Tests — unit, integration, manual blue-green checklist
### First target: Dialog
**Why Dialog:**
- 1 atom (`dialogInternalComponentState` — a queue)
- Has `store.set()` imperative access (exercises the CallbackState path)
- Clearly ephemeral — dialog queue should not survive navigation
- Mounted at app root (`AppRouterProviders`) — full lifecycle visible
- Isolated blast radius — breaking it is immediately visible in any flow
- Existing test coverage as baseline
**Expansion order after Dialog:**
1. TabList (2 atoms, session semantics — tests `onRestore` path)
2. ClickOutsideListener (3 atoms, pure utility)
3. Dropdown (5 atoms, widely used — higher risk, proves scale)
4. RecordTable (high impact, high atom count — ultimate target)
---
## What This PR Delivers (Task 01 — Foundation)
All files under `packages/twenty-front/src/modules/ui/utilities/state/`.
### New types
| File | Purpose |
|---|---|
| `component-state/types/ComponentStateLifecyclePhase.ts` | `'mounted' \| 'active' \| 'dormant' \| 'evicted'` |
### New utilities
| File | Purpose |
|---|---|
| `component-state/utils/componentStateSubscriberRegistry.ts` | Tracks subscriber counts per `stateKey × instanceId`. Exposes increment, decrement, get, getTotalForInstance, clear. |
| `component-state/utils/componentStateContextScopeRegistry.ts` | Maps `instanceId → Set<cleanupFn>`. Every atom factory registers its own cache-eviction callback here. `destroyComponentStateContextScope(instanceId)` calls all registered cleanups. |
### Modified types
`ComponentState<V>` and `ComponentFamilyState<V, K>` both gain:
```ts
cleanup: (instanceId: string) => void;
```
### Modified factories
`createAtomComponentState` and `createAtomComponentFamilyState` now:
1. Register a cleanup callback with `registerAtomCleanupForInstance` on every
new `atomFamily()` call.
2. Expose a `cleanup(instanceId)` method that directly evicts matching cache
entries.
### Modified hooks (8 total)
Every `useAtomComponent*` and `useSetAtomComponent*` hook now runs a
`useEffect` that increments the subscriber count on mount and decrements it on
unmount.
### Tests — 42 passing
| File | Tests | Coverage |
|---|---|---|
| `componentStateSubscriberRegistry.test.ts` | 18 | Increment, decrement, total, cross-instance isolation, clear |
| `componentStateContextScopeRegistry.test.ts` | 12 | Register, destroy, count, factory integration |
| `useAtomComponentStateSubscriberCounting.test.tsx` | 12 | Each hook in a real React tree: count 0→1 on mount, 1→0 on unmount; atom cache eviction after `destroyComponentStateContextScope` |
---
## Invariants to Preserve
- `componentStateSubscriberRegistry` and `componentStateContextScopeRegistry`
are module-level singletons. Tests must call `clearSubscriberCountsForInstance`
and `destroyComponentStateContextScope` in `afterEach` to avoid cross-test leaks.
- `cleanup()` on a state object evicts the factory cache only. It does NOT reset
the Jotai atom value in any live store. That is a later task.
- `destroyComponentStateContextScope` is idempotent.
---
## Key Data Flow
```
[Hook mounts]
useEffect fires
→ incrementComponentStateSubscriberCount(key, instanceId)
[atomFamily() called for first time]
→ creates Jotai atom
→ registerAtomCleanupForInstance(instanceId, () => atomCache.delete(cacheKey))
[Hook unmounts]
useEffect cleanup fires
→ decrementComponentStateSubscriberCount(key, instanceId)
→ if count === 0: (Task 02 will observe this and call…)
→ destroyComponentStateContextScope(instanceId)
→ calls all registered cleanup fns
→ atomCache.delete(cacheKey) for every atom in that scope
```
---
## Out of Scope (Deferred)
- Timer-based GC (good fallback, tracked in decisions.md)
- Cold storage / two-tier cache (strong future direction, tracked above)
- `FamilyState` GC (non-component-scoped — Phase 2)
- Cascade across nested `ComponentInstanceContext`s
- Jotai store value reset after eviction (a later task — factory cache eviction
is sufficient for now)
- Automatic GC without developer-registered lifecycle handlers
@@ -0,0 +1,5 @@
export type ComponentStateLifecyclePhase =
| 'mounted'
| 'active'
| 'dormant'
| 'evicted';
@@ -0,0 +1,135 @@
import {
destroyComponentStateContextScope,
getAllRegisteredInstanceIds,
getRegisteredAtomCountForInstance,
getTotalRegisteredAtomCount,
registerAtomCleanupForInstance,
} from '../componentStateContextScopeRegistry';
const INSTANCE_ID = 'instance-1';
const OTHER_INSTANCE_ID = 'instance-2';
afterEach(() => {
destroyComponentStateContextScope(INSTANCE_ID);
destroyComponentStateContextScope(OTHER_INSTANCE_ID);
});
describe('registerAtomCleanupForInstance', () => {
it('registers a cleanup function for an instanceId', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
expect(getRegisteredAtomCountForInstance(INSTANCE_ID)).toBe(1);
});
it('registers multiple cleanup functions for the same instanceId', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
expect(getRegisteredAtomCountForInstance(INSTANCE_ID)).toBe(3);
});
});
describe('destroyComponentStateContextScope', () => {
it('calls all registered cleanup functions', () => {
const cleanupA = jest.fn();
const cleanupB = jest.fn();
registerAtomCleanupForInstance(INSTANCE_ID, cleanupA);
registerAtomCleanupForInstance(INSTANCE_ID, cleanupB);
destroyComponentStateContextScope(INSTANCE_ID);
expect(cleanupA).toHaveBeenCalledTimes(1);
expect(cleanupB).toHaveBeenCalledTimes(1);
});
it('removes the instanceId from the registry after destruction', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
destroyComponentStateContextScope(INSTANCE_ID);
expect(getRegisteredAtomCountForInstance(INSTANCE_ID)).toBe(0);
expect(getAllRegisteredInstanceIds()).not.toContain(INSTANCE_ID);
});
it('does nothing if instanceId was never registered', () => {
expect(() =>
destroyComponentStateContextScope('never-registered'),
).not.toThrow();
});
it('does not affect other instanceIds', () => {
const cleanupOther = jest.fn();
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(OTHER_INSTANCE_ID, cleanupOther);
destroyComponentStateContextScope(INSTANCE_ID);
expect(cleanupOther).not.toHaveBeenCalled();
expect(getRegisteredAtomCountForInstance(OTHER_INSTANCE_ID)).toBe(1);
});
});
describe('getRegisteredAtomCountForInstance', () => {
it('returns 0 for unknown instanceId', () => {
expect(getRegisteredAtomCountForInstance('unknown')).toBe(0);
});
it('returns correct count', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
expect(getRegisteredAtomCountForInstance(INSTANCE_ID)).toBe(2);
});
});
describe('getTotalRegisteredAtomCount', () => {
it('returns 0 when no instances are registered', () => {
expect(getTotalRegisteredAtomCount()).toBe(0);
});
it('sums atom counts across all instances', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(OTHER_INSTANCE_ID, () => {});
expect(getTotalRegisteredAtomCount()).toBe(3);
});
it('decreases after destroyComponentStateContextScope', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(OTHER_INSTANCE_ID, () => {});
destroyComponentStateContextScope(INSTANCE_ID);
expect(getTotalRegisteredAtomCount()).toBe(1);
});
});
describe('getAllRegisteredInstanceIds', () => {
it('returns empty array when no instances registered', () => {
expect(getAllRegisteredInstanceIds()).toEqual([]);
});
it('returns all registered instanceIds', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
registerAtomCleanupForInstance(OTHER_INSTANCE_ID, () => {});
const ids = getAllRegisteredInstanceIds();
expect(ids).toContain(INSTANCE_ID);
expect(ids).toContain(OTHER_INSTANCE_ID);
});
it('does not include destroyed instanceIds', () => {
registerAtomCleanupForInstance(INSTANCE_ID, () => {});
destroyComponentStateContextScope(INSTANCE_ID);
expect(getAllRegisteredInstanceIds()).not.toContain(INSTANCE_ID);
});
});
describe('factory cleanup integration', () => {
it('cleanup function removes atom from cache when called via destroyComponentStateContextScope', () => {
const mockAtomCache = new Map<string, object>();
const atomRef = {};
mockAtomCache.set(INSTANCE_ID, atomRef);
registerAtomCleanupForInstance(INSTANCE_ID, () => {
mockAtomCache.delete(INSTANCE_ID);
});
expect(mockAtomCache.has(INSTANCE_ID)).toBe(true);
destroyComponentStateContextScope(INSTANCE_ID);
expect(mockAtomCache.has(INSTANCE_ID)).toBe(false);
});
});
@@ -0,0 +1,123 @@
import {
clearSubscriberCountsForInstance,
decrementComponentStateSubscriberCount,
getComponentStateSubscriberCount,
getTotalSubscriberCountForInstance,
incrementComponentStateSubscriberCount,
} from '../componentStateSubscriberRegistry';
const STATE_KEY_A = 'stateKeyA';
const STATE_KEY_B = 'stateKeyB';
const INSTANCE_ID = 'instance-1';
const OTHER_INSTANCE_ID = 'instance-2';
afterEach(() => {
clearSubscriberCountsForInstance(INSTANCE_ID);
clearSubscriberCountsForInstance(OTHER_INSTANCE_ID);
});
describe('incrementComponentStateSubscriberCount', () => {
it('returns 1 on first increment', () => {
expect(
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID),
).toBe(1);
});
it('returns incremented value on subsequent calls', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
expect(
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID),
).toBe(2);
});
it('tracks different state keys independently', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
expect(
incrementComponentStateSubscriberCount(STATE_KEY_B, INSTANCE_ID),
).toBe(1);
});
});
describe('decrementComponentStateSubscriberCount', () => {
it('returns 0 when decrementing from 0', () => {
expect(
decrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID),
).toBe(0);
});
it('returns decremented count', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
expect(
decrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID),
).toBe(1);
});
it('removes the map entry when count reaches 0', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
decrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
expect(getComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID)).toBe(0);
});
it('does not go below 0', () => {
decrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
decrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
expect(getComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID)).toBe(0);
});
});
describe('getComponentStateSubscriberCount', () => {
it('returns 0 for unknown key', () => {
expect(getComponentStateSubscriberCount('unknownKey', INSTANCE_ID)).toBe(0);
});
it('returns correct count after increments', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
expect(getComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID)).toBe(3);
});
});
describe('getTotalSubscriberCountForInstance', () => {
it('returns 0 for unknown instanceId', () => {
expect(getTotalSubscriberCountForInstance('unknown-instance')).toBe(0);
});
it('sums across multiple state keys for the same instanceId', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_B, INSTANCE_ID);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(3);
});
it('does not include counts from other instances', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_A, OTHER_INSTANCE_ID);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
});
it('returns 0 after all subscribers decrement', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_B, INSTANCE_ID);
decrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
decrementComponentStateSubscriberCount(STATE_KEY_B, INSTANCE_ID);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('clearSubscriberCountsForInstance', () => {
it('removes all entries for the given instanceId', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_B, INSTANCE_ID);
clearSubscriberCountsForInstance(INSTANCE_ID);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
it('does not affect other instances', () => {
incrementComponentStateSubscriberCount(STATE_KEY_A, INSTANCE_ID);
incrementComponentStateSubscriberCount(STATE_KEY_A, OTHER_INSTANCE_ID);
clearSubscriberCountsForInstance(INSTANCE_ID);
expect(getTotalSubscriberCountForInstance(OTHER_INSTANCE_ID)).toBe(1);
});
});
@@ -0,0 +1,44 @@
const scopeCleanupRegistry = new Map<string, Set<() => void>>();
export const registerAtomCleanupForInstance = (
instanceId: string,
cleanup: () => void,
): void => {
const existing = scopeCleanupRegistry.get(instanceId);
if (existing !== undefined) {
existing.add(cleanup);
} else {
scopeCleanupRegistry.set(instanceId, new Set([cleanup]));
}
};
export const destroyComponentStateContextScope = (
instanceId: string,
): void => {
const cleanups = scopeCleanupRegistry.get(instanceId);
if (cleanups === undefined) {
return;
}
for (const cleanup of cleanups) {
cleanup();
}
scopeCleanupRegistry.delete(instanceId);
};
export const getRegisteredAtomCountForInstance = (
instanceId: string,
): number => {
return scopeCleanupRegistry.get(instanceId)?.size ?? 0;
};
export const getTotalRegisteredAtomCount = (): number => {
let total = 0;
for (const cleanups of scopeCleanupRegistry.values()) {
total += cleanups.size;
}
return total;
};
export const getAllRegisteredInstanceIds = (): string[] => {
return Array.from(scopeCleanupRegistry.keys());
};
@@ -0,0 +1,59 @@
const subscriberCounts = new Map<string, number>();
const buildSubscriberKey = (stateKey: string, instanceId: string): string =>
`${stateKey}__${instanceId}`;
export const incrementComponentStateSubscriberCount = (
stateKey: string,
instanceId: string,
): number => {
const key = buildSubscriberKey(stateKey, instanceId);
const current = subscriberCounts.get(key) ?? 0;
const next = current + 1;
subscriberCounts.set(key, next);
return next;
};
export const decrementComponentStateSubscriberCount = (
stateKey: string,
instanceId: string,
): number => {
const key = buildSubscriberKey(stateKey, instanceId);
const current = subscriberCounts.get(key) ?? 0;
const next = Math.max(0, current - 1);
if (next === 0) {
subscriberCounts.delete(key);
} else {
subscriberCounts.set(key, next);
}
return next;
};
export const getComponentStateSubscriberCount = (
stateKey: string,
instanceId: string,
): number => {
return subscriberCounts.get(buildSubscriberKey(stateKey, instanceId)) ?? 0;
};
export const getTotalSubscriberCountForInstance = (
instanceId: string,
): number => {
const suffix = `__${instanceId}`;
let total = 0;
for (const [key, count] of subscriberCounts) {
if (key.endsWith(suffix)) {
total += count;
}
}
return total;
};
export const clearSubscriberCountsForInstance = (instanceId: string): void => {
const suffix = `__${instanceId}`;
for (const key of subscriberCounts.keys()) {
if (key.endsWith(suffix)) {
subscriberCounts.delete(key);
}
}
};
@@ -0,0 +1,261 @@
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
import { destroyComponentStateContextScope } from '@/ui/utilities/state/component-state/utils/componentStateContextScopeRegistry';
import {
clearSubscriberCountsForInstance,
getTotalSubscriberCountForInstance,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { useAtomComponentState } from '../useAtomComponentState';
import { useAtomComponentStateValue } from '../useAtomComponentStateValue';
import { useSetAtomComponentState } from '../useSetAtomComponentState';
import { useAtomComponentStateCallbackState } from '../useAtomComponentStateCallbackState';
import { useAtomComponentFamilyState } from '../useAtomComponentFamilyState';
import { useAtomComponentFamilyStateValue } from '../useAtomComponentFamilyStateValue';
import { useSetAtomComponentFamilyState } from '../useSetAtomComponentFamilyState';
import { useAtomComponentFamilyStateCallbackState } from '../useAtomComponentFamilyStateCallbackState';
import { jotaiStore, resetJotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
const TestContext = createComponentInstanceContext();
const INSTANCE_ID = 'test-instance-gc';
const testBooleanState = createAtomComponentState<boolean>({
key: 'testBooleanStateForGCTest',
defaultValue: false,
componentInstanceContext: TestContext,
});
const testFamilyState = createAtomComponentFamilyState<string, { id: string }>({
key: 'testFamilyStateForGCTest',
defaultValue: '',
componentInstanceContext: TestContext,
});
const createWrapper = (instanceId: string) => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={jotaiStore}>
<TestContext.Provider value={{ instanceId }}>
{children}
</TestContext.Provider>
</JotaiProvider>
);
return Wrapper;
};
beforeEach(() => {
resetJotaiStore();
clearSubscriberCountsForInstance(INSTANCE_ID);
destroyComponentStateContextScope(INSTANCE_ID);
});
describe('useAtomComponentStateValue — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useAtomComponentStateValue(testBooleanState),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('useSetAtomComponentState — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useSetAtomComponentState(testBooleanState),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('useAtomComponentState — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useAtomComponentState(testBooleanState),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('useAtomComponentStateCallbackState — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useAtomComponentStateCallbackState(testBooleanState),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('useAtomComponentFamilyStateValue — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useAtomComponentFamilyStateValue(testFamilyState, { id: 'row-1' }),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('useSetAtomComponentFamilyState — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useSetAtomComponentFamilyState(testFamilyState, { id: 'row-1' }),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('useAtomComponentFamilyState — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useAtomComponentFamilyState(testFamilyState, { id: 'row-1' }),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('useAtomComponentFamilyStateCallbackState — subscriber counting', () => {
it('increments subscriber count on mount and decrements on unmount', () => {
const { unmount } = renderHook(
() => useAtomComponentFamilyStateCallbackState(testFamilyState),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('multiple hooks — cumulative subscriber counting', () => {
it('counts all active hooks for an instance', () => {
const hookA = renderHook(
() => useAtomComponentStateValue(testBooleanState),
{ wrapper: createWrapper(INSTANCE_ID) },
);
const hookB = renderHook(
() => useSetAtomComponentState(testBooleanState),
{ wrapper: createWrapper(INSTANCE_ID) },
);
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(2);
hookA.unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(1);
hookB.unmount();
expect(getTotalSubscriberCountForInstance(INSTANCE_ID)).toBe(0);
});
});
describe('destroyComponentStateContextScope — atom cache eviction', () => {
it('removes the atom from the factory cache so re-access creates a fresh atom', () => {
renderHook(() => useAtomComponentStateValue(testBooleanState), {
wrapper: createWrapper(INSTANCE_ID),
}).unmount();
const atomBeforeDestroy = testBooleanState.atomFamily({
instanceId: INSTANCE_ID,
});
destroyComponentStateContextScope(INSTANCE_ID);
const atomAfterDestroy = testBooleanState.atomFamily({
instanceId: INSTANCE_ID,
});
expect(atomAfterDestroy).not.toBe(atomBeforeDestroy);
});
it('fresh atom after cleanup has defaultValue', () => {
jotaiStore.set(
testBooleanState.atomFamily({ instanceId: INSTANCE_ID }),
true,
);
destroyComponentStateContextScope(INSTANCE_ID);
const freshAtom = testBooleanState.atomFamily({ instanceId: INSTANCE_ID });
expect(jotaiStore.get(freshAtom)).toBe(false);
});
it('cleans up family atoms matching the instanceId prefix', () => {
renderHook(
() => useAtomComponentFamilyStateValue(testFamilyState, { id: 'row-1' }),
{ wrapper: createWrapper(INSTANCE_ID) },
).unmount();
renderHook(
() => useAtomComponentFamilyStateValue(testFamilyState, { id: 'row-2' }),
{ wrapper: createWrapper(INSTANCE_ID) },
).unmount();
const atomRow1Before = testFamilyState.atomFamily({
instanceId: INSTANCE_ID,
familyKey: { id: 'row-1' },
});
const atomRow2Before = testFamilyState.atomFamily({
instanceId: INSTANCE_ID,
familyKey: { id: 'row-2' },
});
destroyComponentStateContextScope(INSTANCE_ID);
const atomRow1After = testFamilyState.atomFamily({
instanceId: INSTANCE_ID,
familyKey: { id: 'row-1' },
});
const atomRow2After = testFamilyState.atomFamily({
instanceId: INSTANCE_ID,
familyKey: { id: 'row-2' },
});
expect(atomRow1After).not.toBe(atomRow1Before);
expect(atomRow2After).not.toBe(atomRow2Before);
});
});
@@ -1,7 +1,12 @@
import { useAtom } from 'jotai';
import { useEffect } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentFamilyState } from '@/ui/utilities/state/jotai/types/ComponentFamilyState';
export const useAtomComponentFamilyState = <StateType, FamilyKey>(
@@ -27,5 +32,12 @@ export const useAtomComponentFamilyState = <StateType, FamilyKey>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useAtom(componentState.atomFamily({ instanceId, familyKey }));
};
@@ -1,7 +1,11 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentFamilyState } from '@/ui/utilities/state/jotai/types/ComponentFamilyState';
export const useAtomComponentFamilyStateCallbackState = <StateType, FamilyKey>(
@@ -25,6 +29,13 @@ export const useAtomComponentFamilyStateCallbackState = <StateType, FamilyKey>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentFamilyState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentFamilyState.key, instanceId);
};
}, [componentFamilyState.key, instanceId]);
return useCallback(
(familyKey: FamilyKey) =>
componentFamilyState.atomFamily({ instanceId, familyKey }),
@@ -1,7 +1,12 @@
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentFamilyState } from '@/ui/utilities/state/jotai/types/ComponentFamilyState';
export const useAtomComponentFamilyStateValue = <StateType, FamilyKey>(
@@ -24,5 +29,12 @@ export const useAtomComponentFamilyStateValue = <StateType, FamilyKey>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useAtomValue(componentState.atomFamily({ instanceId, familyKey }));
};
@@ -1,7 +1,12 @@
import { useAtom } from 'jotai';
import { useEffect } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentState } from '@/ui/utilities/state/jotai/types/ComponentState';
export const useAtomComponentState = <StateType>(
@@ -26,5 +31,12 @@ export const useAtomComponentState = <StateType>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useAtom(componentState.atomFamily({ instanceId }));
};
@@ -1,7 +1,11 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentState } from '@/ui/utilities/state/jotai/types/ComponentState';
export const useAtomComponentStateCallbackState = <StateType>(
@@ -23,6 +27,13 @@ export const useAtomComponentStateCallbackState = <StateType>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useMemo(
() => componentState.atomFamily({ instanceId }),
[componentState, instanceId],
@@ -1,7 +1,12 @@
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentState } from '@/ui/utilities/state/jotai/types/ComponentState';
export const useAtomComponentStateValue = <StateType>(
@@ -23,5 +28,12 @@ export const useAtomComponentStateValue = <StateType>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useAtomValue(componentState.atomFamily({ instanceId }));
};
@@ -1,7 +1,12 @@
import { useSetAtom } from 'jotai';
import { useEffect } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentFamilyState } from '@/ui/utilities/state/jotai/types/ComponentFamilyState';
export const useSetAtomComponentFamilyState = <ValueType, FamilyKey>(
@@ -24,5 +29,12 @@ export const useSetAtomComponentFamilyState = <ValueType, FamilyKey>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useSetAtom(componentState.atomFamily({ instanceId, familyKey }));
};
@@ -1,7 +1,12 @@
import { useSetAtom } from 'jotai';
import { useEffect } from 'react';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
decrementComponentStateSubscriberCount,
incrementComponentStateSubscriberCount,
} from '@/ui/utilities/state/component-state/utils/componentStateSubscriberRegistry';
import { type ComponentState } from '@/ui/utilities/state/jotai/types/ComponentState';
export const useSetAtomComponentState = <ValueType>(
@@ -23,5 +28,12 @@ export const useSetAtomComponentState = <ValueType>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useSetAtom(componentState.atomFamily({ instanceId }));
};
@@ -18,4 +18,5 @@ export type ComponentFamilyState<ValueType, FamilyKey> = {
atomFamily: (
key: ComponentFamilyStateKey<FamilyKey>,
) => JotaiWritableAtom<ValueType>;
cleanup: (instanceId: string) => void;
};
@@ -12,4 +12,5 @@ export type ComponentState<ValueType> = {
type: 'ComponentState';
key: string;
atomFamily: (key: ComponentStateKey) => JotaiWritableAtom<ValueType>;
cleanup: (instanceId: string) => void;
};
@@ -2,6 +2,7 @@ import { atom } from 'jotai';
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import { registerAtomCleanupForInstance } from '@/ui/utilities/state/component-state/utils/componentStateContextScopeRegistry';
import {
type ComponentFamilyStateKey,
type ComponentFamilyState,
@@ -46,12 +47,26 @@ export const createAtomComponentFamilyState = <ValueType, FamilyKey>({
baseAtom.debugLabel = `${key}__${cacheKey}`;
atomCache.set(cacheKey, baseAtom);
registerAtomCleanupForInstance(instanceId, () => {
atomCache.delete(cacheKey);
});
return baseAtom;
};
const cleanup = (instanceId: string): void => {
const prefix = `${instanceId}__`;
for (const cacheKey of atomCache.keys()) {
if (cacheKey.startsWith(prefix)) {
atomCache.delete(cacheKey);
}
}
};
return {
type: 'ComponentFamilyState',
key,
atomFamily: familyFunction,
cleanup,
};
};
@@ -3,6 +3,7 @@ import { atom } from 'jotai';
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import { registerAtomCleanupForInstance } from '@/ui/utilities/state/component-state/utils/componentStateContextScopeRegistry';
import { type ComponentState } from '@/ui/utilities/state/jotai/types/ComponentState';
import { isDefined } from 'twenty-shared/utils';
@@ -39,12 +40,21 @@ export const createAtomComponentState = <ValueType>({
baseAtom.debugLabel = `${key}__${instanceId}`;
atomCache.set(instanceId, baseAtom);
registerAtomCleanupForInstance(instanceId, () => {
atomCache.delete(instanceId);
});
return baseAtom;
};
const cleanup = (instanceId: string): void => {
atomCache.delete(instanceId);
};
return {
type: 'ComponentState',
key,
atomFamily: familyFunction,
cleanup,
};
};