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:
Charles Bochet
2026-02-12 16:05:38 +01:00
committed by GitHub
parent 08b962b0d2
commit d2f8352cb8
87 changed files with 1086 additions and 241 deletions
@@ -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);
});
};