Compare commits

..
Author SHA1 Message Date
Paul Rastoinandprastoin c7f6a1cdbe Refactor client auto heal (#19032) 2026-03-27 10:35:32 +01:00
34 changed files with 55 additions and 1528 deletions
@@ -3501,7 +3501,6 @@ type Mutation {
createViewGroup(input: CreateViewGroupInput!): ViewGroup!
createManyViewGroups(inputs: [CreateViewGroupInput!]!): [ViewGroup!]!
updateViewGroup(input: UpdateViewGroupInput!): ViewGroup!
updateManyViewGroups(inputs: [UpdateViewGroupInput!]!): [ViewGroup!]!
deleteViewGroup(input: DeleteViewGroupInput!): ViewGroup!
destroyViewGroup(input: DestroyViewGroupInput!): ViewGroup!
updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder!
@@ -2954,7 +2954,6 @@ export interface Mutation {
createViewGroup: ViewGroup
createManyViewGroups: ViewGroup[]
updateViewGroup: ViewGroup
updateManyViewGroups: ViewGroup[]
deleteViewGroup: ViewGroup
destroyViewGroup: ViewGroup
updateMessageFolder: MessageFolder
@@ -6208,7 +6207,6 @@ export interface MutationGenqlSelection{
createViewGroup?: (ViewGroupGenqlSelection & { __args: {input: CreateViewGroupInput} })
createManyViewGroups?: (ViewGroupGenqlSelection & { __args: {inputs: CreateViewGroupInput[]} })
updateViewGroup?: (ViewGroupGenqlSelection & { __args: {input: UpdateViewGroupInput} })
updateManyViewGroups?: (ViewGroupGenqlSelection & { __args: {inputs: UpdateViewGroupInput[]} })
deleteViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DeleteViewGroupInput} })
destroyViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DestroyViewGroupInput} })
updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} })
@@ -8130,15 +8130,6 @@ export default {
]
}
],
"updateManyViewGroups": [
56,
{
"inputs": [
450,
"[UpdateViewGroupInput!]!"
]
}
],
"deleteViewGroup": [
56,
{
File diff suppressed because one or more lines are too long
@@ -31,6 +31,8 @@ import { useCallback, useState } from 'react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
export const useAgentChat = (
uiMessages: ExtendedUIMessage[],
ensureThreadIdForSend: () => Promise<string | null>,
@@ -92,6 +94,7 @@ export const useAgentChat = (
return null;
}
cookieStorage.setItem('tokenPair', JSON.stringify(renewedTokens));
setTokenPair(renewedTokens);
const updatedHeaders = new Headers(init?.headers ?? {});
@@ -34,6 +34,7 @@ import {
import isEmpty from 'lodash.isempty';
import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const logger = loggerLink(() => 'Twenty');
@@ -174,6 +175,7 @@ export class ApolloFactory implements ApolloManager {
if (isDefined(tokens)) {
onTokenPairChange?.(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
}
};
@@ -91,20 +91,13 @@ export const MultiItemFieldInput = <T,>({
) {
return;
}
const { isValid, updatedItems } = validateInputAndComputeUpdatedItems();
if (isInputDisplayed) {
const { isValid, updatedItems } = validateInputAndComputeUpdatedItems();
if (!isValid) {
return;
}
onChange(updatedItems);
onClickOutside(updatedItems, event);
if (!isValid) {
return;
}
onChange(updatedItems);
onClickOutside(items, event);
},
listenerId: instanceId,
@@ -1,341 +0,0 @@
# 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
@@ -1,5 +0,0 @@
export type ComponentStateLifecyclePhase =
| 'mounted'
| 'active'
| 'dormant'
| 'evicted';
@@ -1,135 +0,0 @@
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);
});
});
@@ -1,123 +0,0 @@
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);
});
});
@@ -1,44 +0,0 @@
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());
};
@@ -1,59 +0,0 @@
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);
}
}
};
@@ -1,261 +0,0 @@
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,12 +1,7 @@
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>(
@@ -32,12 +27,5 @@ 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,11 +1,7 @@
import { useCallback, useEffect } from 'react';
import { useCallback } 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>(
@@ -29,13 +25,6 @@ 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,12 +1,7 @@
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>(
@@ -29,12 +24,5 @@ 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,12 +1,7 @@
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>(
@@ -31,12 +26,5 @@ export const useAtomComponentState = <StateType>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useAtom(componentState.atomFamily({ instanceId }));
};
@@ -1,11 +1,7 @@
import { useEffect, useMemo } from 'react';
import { 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>(
@@ -27,13 +23,6 @@ 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,12 +1,7 @@
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>(
@@ -28,12 +23,5 @@ export const useAtomComponentStateValue = <StateType>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useAtomValue(componentState.atomFamily({ instanceId }));
};
@@ -1,12 +1,7 @@
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>(
@@ -29,12 +24,5 @@ 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,12 +1,7 @@
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>(
@@ -28,12 +23,5 @@ export const useSetAtomComponentState = <ValueType>(
instanceIdFromProps,
);
useEffect(() => {
incrementComponentStateSubscriberCount(componentState.key, instanceId);
return () => {
decrementComponentStateSubscriberCount(componentState.key, instanceId);
};
}, [componentState.key, instanceId]);
return useSetAtom(componentState.atomFamily({ instanceId }));
};
@@ -18,5 +18,4 @@ export type ComponentFamilyState<ValueType, FamilyKey> = {
atomFamily: (
key: ComponentFamilyStateKey<FamilyKey>,
) => JotaiWritableAtom<ValueType>;
cleanup: (instanceId: string) => void;
};
@@ -12,5 +12,4 @@ export type ComponentState<ValueType> = {
type: 'ComponentState';
key: string;
atomFamily: (key: ComponentStateKey) => JotaiWritableAtom<ValueType>;
cleanup: (instanceId: string) => void;
};
@@ -2,7 +2,6 @@ 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,
@@ -47,26 +46,12 @@ 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,7 +3,6 @@ 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';
@@ -40,21 +39,12 @@ 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,
};
};
@@ -1,11 +0,0 @@
import { VIEW_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewGroupFragment';
import { gql } from '@apollo/client';
export const UPDATE_MANY_VIEW_GROUPS = gql`
${VIEW_GROUP_FRAGMENT}
mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) {
updateManyViewGroups(inputs: $inputs) {
...ViewGroupFragment
}
}
`;
@@ -8,44 +8,43 @@ import { t } from '@lingui/core/macro';
import { CrudOperationType } from 'twenty-shared/types';
import { useMutation } from '@apollo/client/react';
import {
type UpdateManyViewGroupsMutationVariables,
UpdateManyViewGroupsDocument,
type UpdateViewGroupMutationVariables,
UpdateViewGroupDocument,
} from '~/generated-metadata/graphql';
export const usePerformViewGroupAPIPersist = () => {
const [updateManyViewGroupsMutation] = useMutation(
UpdateManyViewGroupsDocument,
);
const [updateViewGroupMutation] = useMutation(UpdateViewGroupDocument);
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const performViewGroupAPIUpdate = useCallback(
async (
updateViewGroupInputs: UpdateManyViewGroupsMutationVariables,
updateViewGroupInputs: UpdateViewGroupMutationVariables[],
): Promise<
MetadataRequestResult<Awaited<
ReturnType<typeof updateManyViewGroupsMutation>
> | null>
MetadataRequestResult<
Awaited<ReturnType<typeof updateViewGroupMutation>>[]
>
> => {
if (
!Array.isArray(updateViewGroupInputs.inputs) ||
updateViewGroupInputs.inputs.length === 0
) {
if (updateViewGroupInputs.length === 0) {
return {
status: 'successful',
response: null,
response: [],
};
}
try {
const result = await updateManyViewGroupsMutation({
variables: updateViewGroupInputs,
});
const results = await Promise.all(
updateViewGroupInputs.map((variables) =>
updateViewGroupMutation({
variables,
}),
),
);
return {
status: 'successful',
response: result,
response: results,
};
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
@@ -63,7 +62,7 @@ export const usePerformViewGroupAPIPersist = () => {
};
}
},
[updateManyViewGroupsMutation, handleMetadataError, enqueueErrorSnackBar],
[updateViewGroupMutation, handleMetadataError, enqueueErrorSnackBar],
);
return {
@@ -67,9 +67,9 @@ export const useSaveCurrentViewGroups = () => {
return;
}
await performViewGroupAPIUpdate({
inputs: [
{
await performViewGroupAPIUpdate([
{
input: {
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
@@ -77,8 +77,8 @@ export const useSaveCurrentViewGroups = () => {
fieldValue: viewGroupToSave.fieldValue,
},
},
],
});
},
]);
},
[
store,
@@ -109,7 +109,7 @@ export const useSaveCurrentViewGroups = () => {
const currentViewGroups = view.viewGroups;
const viewGroupInputsToUpdate = viewGroupsToSave
const viewGroupsToUpdate = viewGroupsToSave
.map((viewGroupToSave) => {
const existingField = currentViewGroups.find(
(currentViewGroup) =>
@@ -136,11 +136,13 @@ export const useSaveCurrentViewGroups = () => {
}
return {
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
position: viewGroupToSave.position,
fieldValue: viewGroupToSave.fieldValue,
input: {
id: existingField.id,
update: {
isVisible: viewGroupToSave.isVisible,
position: viewGroupToSave.position,
fieldValue: viewGroupToSave.fieldValue,
},
},
};
})
@@ -150,7 +152,7 @@ export const useSaveCurrentViewGroups = () => {
throw new Error('mainGroupByFieldMetadataId is required');
}
await performViewGroupAPIUpdate({ inputs: viewGroupInputsToUpdate });
await performViewGroupAPIUpdate(viewGroupsToUpdate);
},
[
store,
@@ -84,19 +84,6 @@ export class ViewGroupResolver {
});
}
@Mutation(() => [ViewGroupDTO])
@UseGuards(UpdateViewGroupPermissionGuard)
async updateManyViewGroups(
@Args('inputs', { type: () => [UpdateViewGroupInput] })
updateViewGroupInputs: UpdateViewGroupInput[],
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewGroupDTO[]> {
return await this.viewGroupService.updateMany({
updateViewGroupInputs,
workspaceId,
});
}
@Mutation(() => ViewGroupDTO)
@UseGuards(DeleteViewGroupPermissionGuard)
async deleteViewGroup(
@@ -7,7 +7,6 @@ import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateViewGroupInputToFlatViewGroupToCreate } from 'src/engine/metadata-modules/flat-view-group/utils/from-create-view-group-input-to-flat-view-group-to-create.util';
@@ -153,32 +152,6 @@ export class ViewGroupService {
workspaceId: string;
updateViewGroupInput: UpdateViewGroupInput;
}): Promise<ViewGroupDTO> {
const [updatedViewGroup] = await this.updateMany({
updateViewGroupInputs: [updateViewGroupInput],
workspaceId,
});
if (!isDefined(updatedViewGroup)) {
throw new ViewGroupException(
'Failed to update view group',
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
);
}
return updatedViewGroup;
}
async updateMany({
updateViewGroupInputs,
workspaceId,
}: {
updateViewGroupInputs: UpdateViewGroupInput[];
workspaceId: string;
}): Promise<ViewGroupDTO[]> {
if (updateViewGroupInputs.length === 0) {
return [];
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
@@ -194,13 +167,11 @@ export class ViewGroupService {
},
);
const flatViewGroupsToUpdate = updateViewGroupInputs.map(
(updateViewGroupInput) =>
fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({
flatViewGroupMaps: existingFlatViewGroupMaps,
updateViewGroupInput,
}),
);
const optimisticallyUpdatedFlatViewGroup =
fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({
flatViewGroupMaps: existingFlatViewGroupMaps,
updateViewGroupInput,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
@@ -209,7 +180,7 @@ export class ViewGroupService {
viewGroup: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: flatViewGroupsToUpdate,
flatEntityToUpdate: [optimisticallyUpdatedFlatViewGroup],
},
},
workspaceId,
@@ -222,7 +193,7 @@ export class ViewGroupService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating view groups',
'Multiple validation errors occurred while updating view group',
);
}
@@ -234,13 +205,12 @@ export class ViewGroupService {
},
);
return updateViewGroupInputs.map(({ id }) =>
fromFlatViewGroupToViewGroupDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedExistingFlatViewGroupMaps,
}),
),
return fromFlatViewGroupToViewGroupDto(
findFlatEntityByUniversalIdentifierOrThrow({
universalIdentifier:
optimisticallyUpdatedFlatViewGroup.universalIdentifier,
flatEntityMaps: recomputedExistingFlatViewGroupMaps,
}),
);
}
@@ -1,235 +0,0 @@
import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/create-many-view-groups.util';
import { deleteOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/delete-one-view-group.util';
import { destroyOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/destroy-one-view-group.util';
import { updateManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/update-many-view-groups.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { isDefined } from 'twenty-shared/utils';
import { type CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
describe('View Group Resolver - Successful Update Many Operations - v2', () => {
let testSetup: {
testViewId: string;
testObjectMetadataId: string;
};
let createdViewGroupIds: string[] = [];
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'myUpdateManyGroupTestObjectV2',
namePlural: 'myUpdateManyGroupTestObjectsV2',
labelSingular: 'My Update Many Group Test Object v2',
labelPlural: 'My Update Many Group Test Objects v2',
icon: 'Icon123',
},
});
const { selectFieldMetadataId } =
await createOneSelectFieldMetadataForIntegrationTests({
input: {
objectMetadataId,
},
});
const {
data: {
createView: { id: testViewId },
},
} = await createOneView({
input: {
icon: 'icon123',
objectMetadataId,
name: 'TestViewForUpdateManyGroups',
mainGroupByFieldMetadataId: selectFieldMetadataId,
},
expectToFail: false,
});
testSetup = {
testViewId,
testObjectMetadataId: objectMetadataId,
};
});
afterAll(async () => {
await updateOneObjectMetadata({
input: {
idToUpdate: testSetup.testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testSetup.testObjectMetadataId },
});
});
afterEach(async () => {
for (const viewGroupId of createdViewGroupIds) {
if (isDefined(viewGroupId)) {
const {
data: { deleteViewGroup },
} = await deleteOneViewGroup({
expectToFail: false,
input: {
id: viewGroupId,
},
});
expect(deleteViewGroup.deletedAt).not.toBeNull();
await destroyOneViewGroup({
expectToFail: false,
input: {
id: viewGroupId,
},
});
}
}
createdViewGroupIds = [];
});
it('should batch-update positions of multiple view groups at once', async () => {
const createInputs: CreateViewGroupInput[] = [
{
viewId: testSetup.testViewId,
position: 0,
isVisible: true,
fieldValue: 'Group A',
},
{
viewId: testSetup.testViewId,
position: 1,
isVisible: true,
fieldValue: 'Group B',
},
{
viewId: testSetup.testViewId,
position: 2,
isVisible: true,
fieldValue: 'Group C',
},
];
const {
data: { createManyViewGroups: createdGroups },
} = await createManyViewGroups({
inputs: createInputs,
expectToFail: false,
});
createdViewGroupIds = createdGroups.map(
(viewGroup: { id: string }) => viewGroup.id,
);
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [
{ id: createdGroups[0].id, update: { position: 2 } },
{ id: createdGroups[1].id, update: { position: 0 } },
{ id: createdGroups[2].id, update: { position: 1 } },
],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(3);
expect(updatedGroups[0]).toMatchObject({
id: createdGroups[0].id,
position: 2,
});
expect(updatedGroups[1]).toMatchObject({
id: createdGroups[1].id,
position: 0,
});
expect(updatedGroups[2]).toMatchObject({
id: createdGroups[2].id,
position: 1,
});
});
it('should batch-update visibility of multiple view groups at once', async () => {
const createInputs: CreateViewGroupInput[] = [
{
viewId: testSetup.testViewId,
position: 0,
isVisible: true,
fieldValue: 'Visible Group',
},
{
viewId: testSetup.testViewId,
position: 1,
isVisible: true,
fieldValue: 'To Be Hidden Group',
},
];
const {
data: { createManyViewGroups: createdGroups },
} = await createManyViewGroups({
inputs: createInputs,
expectToFail: false,
});
createdViewGroupIds = createdGroups.map(
(viewGroup: { id: string }) => viewGroup.id,
);
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [
{ id: createdGroups[0].id, update: { isVisible: false } },
{
id: createdGroups[1].id,
update: { isVisible: false, position: 5 },
},
],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(2);
expect(updatedGroups[0]).toMatchObject({
id: createdGroups[0].id,
isVisible: false,
});
expect(updatedGroups[1]).toMatchObject({
id: createdGroups[1].id,
isVisible: false,
position: 5,
});
});
it('should return empty array for empty inputs', async () => {
const {
data: { updateManyViewGroups: updatedGroups },
errors,
} = await updateManyViewGroups({
inputs: [],
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(updatedGroups).toBeDefined();
expect(updatedGroups).toHaveLength(0);
});
});
@@ -1,23 +0,0 @@
import gql from 'graphql-tag';
import { VIEW_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { type UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
export const updateManyViewGroupsQueryFactory = ({
gqlFields = VIEW_GROUP_GQL_FIELDS,
inputs,
}: {
gqlFields?: string;
inputs: UpdateViewGroupInput[];
}) => ({
query: gql`
mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) {
updateManyViewGroups(inputs: $inputs) {
${gqlFields}
}
}
`,
variables: {
inputs,
},
});
@@ -1,45 +0,0 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateManyViewGroupsQueryFactory } from 'test/integration/metadata/suites/view-group/utils/update-many-view-groups-query-factory.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
export const updateManyViewGroups = async ({
inputs,
gqlFields,
expectToFail,
}: {
inputs: UpdateViewGroupInput[];
gqlFields?: string;
expectToFail?: boolean;
}): CommonResponseBody<{
updateManyViewGroups: ViewGroupEntity[];
}> => {
const graphqlOperation = updateManyViewGroupsQueryFactory({
inputs,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'View Groups batch update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage:
'View Groups batch update has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};