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:
@@ -1,4 +1,5 @@
|
||||
import { MockedProvider, type MockedResponse } from '@apollo/client/testing';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
import { RecoilRoot, type MutableSnapshot } from 'recoil';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { ContextStoreComponentInstanceContext } from '@/context-store/states/con
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
|
||||
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
import { type InMemoryCache } from '@apollo/client';
|
||||
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
|
||||
@@ -26,28 +28,30 @@ export const getJestMetadataAndApolloMocksWrapper = ({
|
||||
objectMetadataItems?: ObjectMetadataItem[];
|
||||
}) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<RecoilRoot initializeState={onInitializeRecoilSnapshot}>
|
||||
<SnackBarComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'snack-bar-manager' }}
|
||||
>
|
||||
<MockedProvider mocks={apolloMocks} addTypename={false} cache={cache}>
|
||||
<RecordComponentInstanceContextsWrapper componentInstanceId="instanceId">
|
||||
<ViewComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'instanceId' }}
|
||||
>
|
||||
<JestObjectMetadataItemSetter
|
||||
objectMetadataItems={objectMetadataItems}
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<RecoilRoot initializeState={onInitializeRecoilSnapshot}>
|
||||
<SnackBarComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'snack-bar-manager' }}
|
||||
>
|
||||
<MockedProvider mocks={apolloMocks} addTypename={false} cache={cache}>
|
||||
<RecordComponentInstanceContextsWrapper componentInstanceId="instanceId">
|
||||
<ViewComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'instanceId' }}
|
||||
>
|
||||
<ContextStoreComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'instanceId' }}
|
||||
<JestObjectMetadataItemSetter
|
||||
objectMetadataItems={objectMetadataItems}
|
||||
>
|
||||
<JestContextStoreSetter>{children}</JestContextStoreSetter>
|
||||
</ContextStoreComponentInstanceContext.Provider>
|
||||
</JestObjectMetadataItemSetter>
|
||||
</ViewComponentInstanceContext.Provider>
|
||||
</RecordComponentInstanceContextsWrapper>
|
||||
</MockedProvider>
|
||||
</SnackBarComponentInstanceContext.Provider>
|
||||
</RecoilRoot>
|
||||
<ContextStoreComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'instanceId' }}
|
||||
>
|
||||
<JestContextStoreSetter>{children}</JestContextStoreSetter>
|
||||
</ContextStoreComponentInstanceContext.Provider>
|
||||
</JestObjectMetadataItemSetter>
|
||||
</ViewComponentInstanceContext.Provider>
|
||||
</RecordComponentInstanceContextsWrapper>
|
||||
</MockedProvider>
|
||||
</SnackBarComponentInstanceContext.Provider>
|
||||
</RecoilRoot>
|
||||
</JotaiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user