Start Jotai Migration (#17893)
## Recoil → Jotai progressive migration: infrastructure + ChipFieldDisplay ### Benchmark In the beginning, there was no hope: <img width="1180" height="948" alt="image" src="https://github.com/user-attachments/assets/f8635991-52e6-4958-8240-6ba7214132b2" /> Then the hope was reborn <img width="2070" height="948" alt="image" src="https://github.com/user-attachments/assets/be1182b9-1c8d-4fdc-ab4c-1484ad74449d" /> ### Approach We introduce a **V2 state management layer** backed by Jotai that mirrors the existing Recoil API, enabling component-by-component migration without a big-bang rewrite. #### V2 API (Jotai-backed, Recoil-ergonomic) - `createStateV2` / `createFamilyStateV2` — drop-in replacements for `createState` / `createFamilyState`, returning wrapper types over Jotai atoms - `useRecoilValueV2`, `useRecoilStateV2`, `useFamilyRecoilValueV2`, etc. — thin wrappers around Jotai's `useAtomValue` / `useAtom` / `useSetAtom` - A shared `jotaiStore` (via `createStore()`) passed to a `<JotaiProvider>` wrapping `<RecoilRoot>`, also accessible imperatively for dual-writes #### Dual-write bridge for progressive migration For state shared between migrated and non-migrated components, we use **dual-write**: writers update both the Recoil atom and the Jotai V2 atom (via `jotaiStore.set()`). This avoids sync components or extra subscriptions. Write sites updated: `useUpsertRecordsInStore`, `useSetRecordTableData`, `ListenRecordUpdatesEffect`, `RecordShowEffect`, `useLoadRecordIndexStates`, `useUpdateObjectViewOptions`. #### First migration: ChipFieldDisplay render path - `useChipFieldDisplay` → reads `recordStoreFamilyStateV2` via `useFamilyRecoilValueV2` (was `useRecoilValue(recordStoreFamilyState)`) - `RecordChip` → reads `recordIndexOpenRecordInStateV2` via `useRecoilValueV2` (was `useRecoilValue(recordIndexOpenRecordInState)`) - `Avatar` (twenty-ui) and event handlers (`useOpenRecordInCommandMenu`) left on Recoil — not on the render path / in a different package #### Pattern for migrating additional state 1. Create V2 atom: `createStateV2` or `createFamilyStateV2` 2. Add `jotaiStore.set(v2Atom, value)` at each write site 3. Switch readers to `useRecoilValueV2(v2Atom)` 4. Once all readers are migrated, remove the Recoil atom and dual-writes #### Why not jotai-recoil-adapter? Evaluated [jotai-recoil-adapter](https://github.com/clockelliptic/jotai-recoil-adapter) — not production-ready (21 open issues, no React 19, forces providerless mode, missing types). We built a purpose-built thin layer instead.
This commit is contained in:
+50
-46
@@ -13,6 +13,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
|
||||
import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { recordStoreFamilyStateV2 } from '@/object-record/record-store/states/recordStoreFamilyStateV2';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { FieldWidget } from '@/page-layout/widgets/field/components/FieldWidget';
|
||||
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
|
||||
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import {
|
||||
PageLayoutTabLayoutMode,
|
||||
@@ -256,6 +258,16 @@ const mockCompanyRecord: ObjectRecord = {
|
||||
},
|
||||
};
|
||||
|
||||
// Sets a record in both Recoil and Jotai stores so field display hooks can read it
|
||||
const setRecordInStores = (
|
||||
snapshot: MutableSnapshot,
|
||||
recordId: string,
|
||||
record: ObjectRecord,
|
||||
) => {
|
||||
snapshot.set(recordStoreFamilyState(recordId), record);
|
||||
jotaiStore.set(recordStoreFamilyStateV2.atomFamily(recordId), record);
|
||||
};
|
||||
|
||||
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: [],
|
||||
});
|
||||
@@ -369,7 +381,7 @@ export const TextFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -461,7 +473,7 @@ export const AddressFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -556,7 +568,7 @@ export const NumberFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -648,7 +660,7 @@ export const LinkFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -740,14 +752,15 @@ export const ManyToOneRelationFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
// Set the related WorkspaceMember record for relation field display
|
||||
if (
|
||||
mockCompanyRecord.accountOwner !== null &&
|
||||
mockCompanyRecord.accountOwner !== undefined
|
||||
) {
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(mockCompanyRecord.accountOwner.id),
|
||||
setRecordInStores(
|
||||
snapshot,
|
||||
mockCompanyRecord.accountOwner.id,
|
||||
mockCompanyRecord.accountOwner,
|
||||
);
|
||||
}
|
||||
@@ -842,12 +855,9 @@ export const OneToManyRelationFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
// Set the related Person record for ONE_TO_MANY relation display
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_PERSON_RECORD_ID),
|
||||
mockPersonRecord,
|
||||
);
|
||||
setRecordInStores(snapshot, TEST_PERSON_RECORD_ID, mockPersonRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -939,7 +949,7 @@ export const BooleanFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1030,7 +1040,7 @@ export const CurrencyFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1121,10 +1131,7 @@ export const EmailsFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_PERSON_RECORD_ID),
|
||||
mockPersonRecord,
|
||||
);
|
||||
setRecordInStores(snapshot, TEST_PERSON_RECORD_ID, mockPersonRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1216,10 +1223,7 @@ export const PhonesFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_PERSON_RECORD_ID),
|
||||
mockPersonRecord,
|
||||
);
|
||||
setRecordInStores(snapshot, TEST_PERSON_RECORD_ID, mockPersonRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1311,8 +1315,9 @@ export const SelectFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_OPPORTUNITY_RECORD_ID),
|
||||
setRecordInStores(
|
||||
snapshot,
|
||||
TEST_OPPORTUNITY_RECORD_ID,
|
||||
mockOpportunityRecord,
|
||||
);
|
||||
};
|
||||
@@ -1407,7 +1412,7 @@ export const MultiSelectFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1503,13 +1508,15 @@ export const TimelineActivityRelationFieldWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_TIMELINE_ACTIVITY_RECORD_ID),
|
||||
setRecordInStores(
|
||||
snapshot,
|
||||
TEST_TIMELINE_ACTIVITY_RECORD_ID,
|
||||
mockTimelineActivityRecord,
|
||||
);
|
||||
// Set the related WorkspaceMember record for TimelineActivity relation display
|
||||
snapshot.set(
|
||||
recordStoreFamilyState('test-workspace-member-xyz'),
|
||||
setRecordInStores(
|
||||
snapshot,
|
||||
'test-workspace-member-xyz',
|
||||
mockWorkspaceMemberRecord,
|
||||
);
|
||||
};
|
||||
@@ -1603,13 +1610,14 @@ export const ManyToOneRelationCardWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
if (
|
||||
mockCompanyRecord.accountOwner !== null &&
|
||||
mockCompanyRecord.accountOwner !== undefined
|
||||
) {
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(mockCompanyRecord.accountOwner.id),
|
||||
setRecordInStores(
|
||||
snapshot,
|
||||
mockCompanyRecord.accountOwner.id,
|
||||
mockCompanyRecord.accountOwner,
|
||||
);
|
||||
}
|
||||
@@ -1713,11 +1721,8 @@ export const OneToManyRelationCardWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_PERSON_RECORD_ID),
|
||||
mockPersonRecord,
|
||||
);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, mockCompanyRecord);
|
||||
setRecordInStores(snapshot, TEST_PERSON_RECORD_ID, mockPersonRecord);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1809,12 +1814,14 @@ export const TimelineActivityRelationCardWidget: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_TIMELINE_ACTIVITY_RECORD_ID),
|
||||
setRecordInStores(
|
||||
snapshot,
|
||||
TEST_TIMELINE_ACTIVITY_RECORD_ID,
|
||||
mockTimelineActivityRecord,
|
||||
);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState('test-workspace-member-xyz'),
|
||||
setRecordInStores(
|
||||
snapshot,
|
||||
'test-workspace-member-xyz',
|
||||
mockWorkspaceMemberRecord,
|
||||
);
|
||||
};
|
||||
@@ -1971,13 +1978,10 @@ export const OneToManyRelationCardWidgetWithProgressiveLoading: Story = {
|
||||
}),
|
||||
pageLayoutData,
|
||||
);
|
||||
snapshot.set(
|
||||
recordStoreFamilyState(TEST_RECORD_ID),
|
||||
companyWithManyPeople,
|
||||
);
|
||||
setRecordInStores(snapshot, TEST_RECORD_ID, companyWithManyPeople);
|
||||
// Set each person record in the store
|
||||
mockPeople.forEach((person) => {
|
||||
snapshot.set(recordStoreFamilyState(person.id), person);
|
||||
setRecordInStores(snapshot, person.id, person);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user