Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f6a1cdbe |
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+3
-10
@@ -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,
|
||||
|
||||
-341
@@ -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) | 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
@@ -1,5 +0,0 @@
|
||||
export type ComponentStateLifecyclePhase =
|
||||
| 'mounted'
|
||||
| 'active'
|
||||
| 'dormant'
|
||||
| 'evicted';
|
||||
-135
@@ -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);
|
||||
});
|
||||
});
|
||||
-123
@@ -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);
|
||||
});
|
||||
});
|
||||
-44
@@ -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());
|
||||
};
|
||||
-59
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
-261
@@ -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);
|
||||
});
|
||||
});
|
||||
-12
@@ -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
-12
@@ -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 }),
|
||||
|
||||
-12
@@ -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 }));
|
||||
};
|
||||
|
||||
-12
@@ -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
-12
@@ -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],
|
||||
|
||||
-12
@@ -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 }));
|
||||
};
|
||||
|
||||
-12
@@ -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 }));
|
||||
};
|
||||
|
||||
-12
@@ -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 }));
|
||||
};
|
||||
|
||||
-1
@@ -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;
|
||||
};
|
||||
|
||||
-15
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
-10
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+18
-19
@@ -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,
|
||||
|
||||
-13
@@ -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(
|
||||
|
||||
+13
-43
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
-235
@@ -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);
|
||||
});
|
||||
});
|
||||
-23
@@ -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,
|
||||
},
|
||||
});
|
||||
-45
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user