Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4b99b76c | ||
|
|
fffb7a8057 | ||
|
|
3ec78832ba |
+341
@@ -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) | 500k–10M+ | Grows monotonically |
|
||||
| Estimated memory | 100MB–1GB+ | — |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type ComponentStateLifecyclePhase =
|
||||
| 'mounted'
|
||||
| 'active'
|
||||
| 'dormant'
|
||||
| 'evicted';
|
||||
+135
@@ -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);
|
||||
});
|
||||
});
|
||||
+123
@@ -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);
|
||||
});
|
||||
});
|
||||
+44
@@ -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());
|
||||
};
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
+261
@@ -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);
|
||||
});
|
||||
});
|
||||
+12
@@ -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 }));
|
||||
};
|
||||
|
||||
+12
-1
@@ -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 }),
|
||||
|
||||
+12
@@ -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 }));
|
||||
};
|
||||
|
||||
+12
@@ -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 }));
|
||||
};
|
||||
|
||||
+12
-1
@@ -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],
|
||||
|
||||
+12
@@ -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 }));
|
||||
};
|
||||
|
||||
+12
@@ -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 }));
|
||||
};
|
||||
|
||||
+12
@@ -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 }));
|
||||
};
|
||||
|
||||
+1
@@ -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;
|
||||
};
|
||||
|
||||
+15
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+10
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user