refactor(web): sync app context effects with jotai (#38570)

This commit is contained in:
Stephen Zhou 2026-07-08 23:55:25 +08:00 committed by GitHub
parent 9416d04163
commit caf1a22020
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 274 additions and 188 deletions

17
pnpm-lock.yaml generated
View File

@ -405,6 +405,9 @@ catalogs:
jotai:
specifier: 2.20.1
version: 2.20.1
jotai-effect:
specifier: 2.3.1
version: 2.3.1
jotai-scope:
specifier: 0.11.0
version: 0.11.0
@ -1252,6 +1255,9 @@ importers:
jotai:
specifier: 'catalog:'
version: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)
jotai-effect:
specifier: 'catalog:'
version: 2.3.1(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))
jotai-scope:
specifier: 'catalog:'
version: 0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
@ -7364,6 +7370,12 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jotai-effect@2.3.1:
resolution: {integrity: sha512-FBBVXDM2podbbxJsZ19+uDv45LxeXoVA5yh6CfkQ35AVCuRHj7Lanlcjiea3b67Q7+/MMGXWf8+GeB73JcbeMg==}
engines: {node: '>=12.20.0'}
peerDependencies:
jotai: '>=2.20.0'
jotai-scope@0.11.0:
resolution: {integrity: sha512-ofiW0Z0i3lTw509Gx0+T6fqsDPMDxMn+AHmNs9iF9OA8CmK1/0xRprPxuZ89UZdBzt6jcrTYdunNZSF2255zJQ==}
engines: {node: '>=14.0.0'}
@ -16663,6 +16675,10 @@ snapshots:
jiti@2.7.0: {}
jotai-effect@2.3.1(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)):
dependencies:
jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)
jotai-scope@0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):
dependencies:
jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)
@ -20034,6 +20050,7 @@ time:
i18next@26.3.3: '2026-06-26T11:23:58.220Z'
iconify-import-svg@0.2.0: '2026-04-20T06:18:25.132Z'
immer@11.1.8: '2026-05-08T15:09:33.021Z'
jotai-effect@2.3.1: '2026-05-13T18:51:15.911Z'
jotai-scope@0.11.0: '2026-05-13T18:43:15.331Z'
jotai-tanstack-query@0.11.0: '2025-08-01T02:55:49.826Z'
jotai@2.20.1: '2026-06-11T06:30:45.782Z'

View File

@ -185,6 +185,7 @@ catalog:
iconify-import-svg: 0.2.0
immer: 11.1.8
jotai: 2.20.1
jotai-effect: 2.3.1
jotai-scope: 0.11.0
jotai-tanstack-query: 0.11.0
js-audio-recorder: 1.0.7

View File

@ -0,0 +1,12 @@
'use client'
import { useAtomValue } from 'jotai'
import { amplitudeIdentitySyncAtom } from '@/context/amplitude-identity-sync'
import { zendeskConversationSyncAtom } from '@/context/zendesk-conversation-sync'
export function ExternalServiceSync() {
useAtomValue(zendeskConversationSyncAtom)
useAtomValue(amplitudeIdentitySyncAtom)
return null
}

View File

@ -61,28 +61,22 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
const queryClient = getQueryClientServer()
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (!accountProfileUrl) {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}
if (accountProfileUrl) {
try {
const context = await getServerConsoleClientContext()
try {
const context = await getServerConsoleClientContext()
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()),
queryClient.prefetchQuery(serverConsoleQuery.workspaces.current.post.queryOptions({
context,
retry: false,
})),
])
}
catch (error) {
await handleProfileError(error)
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()),
queryClient.prefetchQuery(serverConsoleQuery.workspaces.current.post.queryOptions({
context,
retry: false,
})),
])
}
catch (error) {
await handleProfileError(error)
}
}
return (

View File

@ -1,18 +1,10 @@
import * as React from 'react'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppBootstrapEffects } from '@/context/app-bootstrap-effects'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import { CommonLayoutGlobalMounts } from './global-mounts'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
import { ConsoleContextProviders, ConsoleRuntimeProviders } from './providers'
export default async function Layout({
children,
@ -23,28 +15,19 @@ export default async function Layout({
}) {
return (
<React.Fragment>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<ConsoleRuntimeProviders>
<NextRouteStateBridge>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<AppBootstrapEffects />
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout detailSidebar={detailSidebar}>
{children}
</MainNavLayout>
<CommonLayoutGlobalMounts />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
<ConsoleContextProviders>
<MainNavLayout detailSidebar={detailSidebar}>
{children}
</MainNavLayout>
<CommonLayoutGlobalMounts />
</ConsoleContextProviders>
</div>
</NextRouteStateBridge>
</CommonLayoutHydrationBoundary>
</ConsoleRuntimeProviders>
<Zendesk />
</React.Fragment>
)

View File

@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import { ExternalServiceSync } from './external-service-sync'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
export async function ConsoleRuntimeProviders({ children }: { children: ReactNode }) {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<ExternalServiceSync />
{children}
</CommonLayoutHydrationBoundary>
</>
)
}
export function ConsoleContextProviders({ children }: { children: ReactNode }) {
return (
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
{children}
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
)
}

View File

@ -1,42 +1,25 @@
import * as React from 'react'
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { ConsoleContextProviders, ConsoleRuntimeProviders } from '@/app/(commonLayout)/providers'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppBootstrapEffects } from '@/context/app-bootstrap-effects'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import Header from './header'
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<React.Fragment>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<ConsoleRuntimeProviders>
<div className="flex h-full flex-col overflow-hidden bg-background-body">
<MaintenanceNotice />
<AppBootstrapEffects />
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 min-h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
<ConsoleContextProviders>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 min-h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ConsoleContextProviders>
</div>
</CommonLayoutHydrationBoundary>
</ConsoleRuntimeProviders>
</React.Fragment>
)
}

View File

@ -48,8 +48,8 @@ export function OAuthRegistrationAnalytics() {
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
// Defer the Amplitude event until the user ID is attached. It is flushed in
// AppBootstrapEffects after setUserId runs. Firing it here would record it under an
// Defer the Amplitude event until the user ID is attached. The app context
// external sync replays it after setUserId runs. Firing it here would record it under an
// anonymous Amplitude profile (no user ID set yet).
rememberRegistrationSuccess({ method: 'oauth', utmInfo })

View File

@ -78,8 +78,8 @@ const ChangePasswordForm = () => {
if (result === 'success') {
const utmInfo = parseUtmInfo()
rememberCreateAppExternalAttribution({ utmInfo })
// Defer the Amplitude event until the user ID is attached. It is flushed in
// AppBootstrapEffects after setUserId runs once the redirect lands on /apps.
// Defer the Amplitude event until the user ID is attached. The app context
// external sync replays it after setUserId runs once the redirect lands on /apps.
// Firing it here would record it under an anonymous Amplitude profile.
rememberRegistrationSuccess({ method: 'email', utmInfo })

View File

@ -5,11 +5,11 @@ import { Provider as JotaiProvider, useAtomValue, useSetAtom } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/react/utils'
import { Suspense } from 'react'
import { ExternalServiceSync } from '@/app/(commonLayout)/external-service-sync'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { AppBootstrapEffects } from '../app-bootstrap-effects'
import { initialWorkspaceInfo } from '../app-context-defaults'
import {
currentWorkspaceAtom,
@ -167,7 +167,7 @@ vi.mock('@/app/components/header/maintenance-notice', () => ({
default: () => null,
}))
function AppContextProbe() {
function ConsoleBootstrapProbe() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const roleFlags = useAtomValue(workspaceRoleFlagsAtom)
@ -257,15 +257,15 @@ function createTestQueryClient() {
})
}
function renderBootstrapEffects() {
function renderConsoleBootstrap() {
const queryClient = createTestQueryClient()
const view = render(
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<TestQueryClientHydrator queryClient={queryClient}>
<Suspense fallback={<span>loading</span>}>
<AppBootstrapEffects />
<AppContextProbe />
<ExternalServiceSync />
<ConsoleBootstrapProbe />
</Suspense>
</TestQueryClientHydrator>
</QueryClientProvider>
@ -278,7 +278,7 @@ function renderBootstrapEffects() {
}
}
describe('AppBootstrapEffects', () => {
describe('Console bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPermissionKeysState.isPending = false
@ -339,7 +339,7 @@ describe('AppBootstrapEffects', () => {
describe('Bootstrap atoms', () => {
it('should provide profile, workspace, permissions, loading state, and version metadata', async () => {
renderBootstrapEffects()
renderConsoleBootstrap()
expect(await screen.findByText('user:user@example.com')).toBeInTheDocument()
expect(await screen.findByText('workspace:Workspace')).toBeInTheDocument()
@ -360,7 +360,7 @@ describe('AppBootstrapEffects', () => {
mockPermissionKeysState.permissionKeys = []
mockLangGeniusVersionState.data = undefined
renderBootstrapEffects()
renderConsoleBootstrap()
expect(await screen.findByText('user:')).toBeInTheDocument()
expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument()
@ -375,7 +375,7 @@ describe('AppBootstrapEffects', () => {
role: 'unsupported-role',
}
renderBootstrapEffects()
renderConsoleBootstrap()
expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
})
@ -386,7 +386,7 @@ describe('AppBootstrapEffects', () => {
role: 'owner',
}
renderBootstrapEffects()
renderConsoleBootstrap()
expect(await screen.findByText('manager:true')).toBeInTheDocument()
expect(screen.getByText('owner:true')).toBeInTheDocument()
@ -398,7 +398,7 @@ describe('AppBootstrapEffects', () => {
mockPermissionKeysState.isPending = true
mockCurrentWorkspaceQueryState.isPending = true
renderBootstrapEffects()
renderConsoleBootstrap()
expect(await screen.findByText('workspace loading:true')).toBeInTheDocument()
expect(screen.getByText('permission loading:true')).toBeInTheDocument()
@ -407,7 +407,7 @@ describe('AppBootstrapEffects', () => {
describe('Refresh actions', () => {
it('should invalidate the source queries when refresh actions are called', async () => {
const { queryClient } = renderBootstrapEffects()
const { queryClient } = renderConsoleBootstrap()
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: /refresh user/i }))
@ -420,7 +420,7 @@ describe('AppBootstrapEffects', () => {
describe('External side effects', () => {
it('should sync Zendesk fields and Amplitude identity when bootstrap data is available', async () => {
renderBootstrapEffects()
renderConsoleBootstrap()
await waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
@ -469,7 +469,7 @@ describe('AppBootstrapEffects', () => {
},
}
renderBootstrapEffects()
renderConsoleBootstrap()
await screen.findByText('user:')
expect(setUserId).not.toHaveBeenCalled()

View File

@ -0,0 +1,65 @@
'use client'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { ICurrentWorkspace } from '@/models/common'
import { atom } from 'jotai'
import { atomEffect } from 'jotai-effect'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import {
currentWorkspaceAtom,
userProfileAtom,
} from './app-context-state'
type AmplitudeProperties = Record<string, string | number | boolean>
const amplitudeIdentityAtom = atom<string | undefined>(undefined)
function buildAmplitudeProperties({
currentWorkspace,
userProfile,
}: {
currentWorkspace: ICurrentWorkspace
userProfile: GetAccountProfileResponse
}) {
const properties: AmplitudeProperties = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
return properties
}
export const amplitudeIdentitySyncAtom = atomEffect((get, set) => {
const userProfile = get(userProfileAtom)
const currentWorkspace = get(currentWorkspaceAtom)
if (!userProfile.id)
return
const properties = buildAmplitudeProperties({
currentWorkspace,
userProfile,
})
const identity = JSON.stringify({
userId: userProfile.email,
properties,
})
if (identity === get.peek(amplitudeIdentityAtom))
return
setUserId(userProfile.email)
setUserProperties(properties)
flushRegistrationSuccess()
set(amplitudeIdentityAtom, identity)
})

View File

@ -1,13 +0,0 @@
'use client'
import {
useSyncAmplitudeIdentity,
useSyncZendeskFields,
} from './app-context-effects'
export function AppBootstrapEffects() {
useSyncZendeskFields()
useSyncAmplitudeIdentity()
return null
}

View File

@ -1,82 +0,0 @@
'use client'
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import {
currentWorkspaceAtom,
langGeniusVersionInfoAtom,
userProfileAtom,
} from './app-context-state'
export function useSyncZendeskFields() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
}])
}
}, [langGeniusVersionInfo?.current_env])
useEffect(() => {
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
}])
}
}, [langGeniusVersionInfo?.version])
useEffect(() => {
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
}])
}
}, [userProfile?.email])
useEffect(() => {
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
}])
}
}, [currentWorkspace?.id])
}
export function useSyncAmplitudeIdentity() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
useEffect(() => {
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, string | number | boolean> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
flushRegistrationSuccess()
}
}, [userProfile, currentWorkspace])
}

View File

@ -0,0 +1,88 @@
'use client'
import { atom } from 'jotai'
import { atomEffect } from 'jotai-effect'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import {
currentWorkspaceAtom,
langGeniusVersionInfoAtom,
userProfileAtom,
} from './app-context-state'
type ZendeskSyncState = {
email?: string
environment?: string
version?: string
workspaceId?: string
}
const zendeskConversationSyncStateAtom = atom<ZendeskSyncState>({})
function syncZendeskField({
fieldId,
previousValue,
setNextValue,
value,
}: {
fieldId: string | undefined
previousValue: string | undefined
setNextValue: (value: string) => void
value: string
}) {
if (!fieldId || !value || value === previousValue)
return false
setZendeskConversationFields([{
id: fieldId,
value,
}])
setNextValue(value)
return true
}
export const zendeskConversationSyncAtom = atomEffect((get, set) => {
const userProfile = get(userProfileAtom)
const currentWorkspace = get(currentWorkspaceAtom)
const langGeniusVersionInfo = get(langGeniusVersionInfoAtom)
const state = get.peek(zendeskConversationSyncStateAtom)
const nextState = { ...state }
let didSync = false
didSync = syncZendeskField({
fieldId: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
previousValue: state.environment,
setNextValue: (value) => {
nextState.environment = value
},
}) || didSync
didSync = syncZendeskField({
fieldId: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
previousValue: state.version,
setNextValue: (value) => {
nextState.version = value
},
}) || didSync
didSync = syncZendeskField({
fieldId: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
previousValue: state.email,
setNextValue: (value) => {
nextState.email = value
},
}) || didSync
didSync = syncZendeskField({
fieldId: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
previousValue: state.workspaceId,
setNextValue: (value) => {
nextState.workspaceId = value
},
}) || didSync
if (didSync)
set(zendeskConversationSyncStateAtom, nextState)
})

View File

@ -109,6 +109,7 @@
"i18next-resources-to-backend": "catalog:",
"immer": "catalog:",
"jotai": "catalog:",
"jotai-effect": "catalog:",
"jotai-scope": "catalog:",
"jotai-tanstack-form": "workspace:*",
"jotai-tanstack-query": "catalog:",