Compare commits

...
7 changed files with 506 additions and 16 deletions
@@ -0,0 +1,222 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import { QueryClient } from '@tanstack/react-query'
import { createStore } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { amplitudeIdentitySyncAtom } from '../amplitude-identity-sync'
type ProfileQueryData = {
profile: GetAccountProfileResponse
meta: {
currentVersion: string | null
currentEnv: string | null
}
}
const profileQueryState = vi.hoisted(() => {
const state = {
resolve: (_value: unknown) => {},
queryFn: () =>
new Promise<unknown>((resolve) => {
state.resolve = resolve
}),
}
return state
})
const mockCurrentWorkspaceResponse = vi.hoisted(() => ({
id: 'workspace-1',
name: 'Workspace',
plan: 'sandbox',
status: 'normal',
created_at: 1704067200,
role: 'editor',
trial_credits: 200,
trial_credits_used: 0,
next_credit_reset_date: 1706745600,
custom_config: {},
}))
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['user-profile'],
queryFn: profileQueryState.queryFn,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
workspaces: {
current: {
post: {
key: () => ['current-workspace'],
queryOptions: (options: {
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
}) => ({
queryKey: ['current-workspace'],
queryFn: async () => mockCurrentWorkspaceResponse,
...options,
}),
},
},
},
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
setUserId: vi.fn(),
setUserProperties: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude/registration-tracking', () => ({
flushRegistrationSuccess: vi.fn(),
}))
function createProfileQueryData(
overrides: Partial<GetAccountProfileResponse> = {},
): ProfileQueryData {
return {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
...overrides,
} as GetAccountProfileResponse,
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
}
function createTestStore() {
const store = createStore()
store.set(
queryClientAtom,
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
}),
)
return store
}
async function flushAsync() {
await new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
// Regression for langgenius/dify#38840: reading a resolved-suspense atom throws while pending.
describe('amplitudeIdentitySyncAtom', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('cold profile cache', () => {
it('should not throw and should skip sync while the profile query is pending', async () => {
const store = createTestStore()
expect(() => store.sub(amplitudeIdentitySyncAtom, () => {})).not.toThrow()
await flushAsync()
expect(setUserId).not.toHaveBeenCalled()
expect(setUserProperties).not.toHaveBeenCalled()
expect(flushRegistrationSuccess).not.toHaveBeenCalled()
})
it('should sync identity when the pending profile query resolves', async () => {
const store = createTestStore()
store.sub(amplitudeIdentitySyncAtom, () => {})
await flushAsync()
profileQueryState.resolve(createProfileQueryData())
await vi.waitFor(() => {
expect(setUserId).toHaveBeenCalledWith('user@example.com')
})
expect(setUserProperties).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
workspace_id: 'workspace-1',
workspace_role: 'editor',
}),
)
expect(flushRegistrationSuccess).toHaveBeenCalledTimes(1)
})
it('should skip sync when the resolved profile has no id', async () => {
const store = createTestStore()
store.sub(amplitudeIdentitySyncAtom, () => {})
await flushAsync()
profileQueryState.resolve(createProfileQueryData({ id: '', email: '', name: '' }))
await flushAsync()
expect(setUserId).not.toHaveBeenCalled()
expect(setUserProperties).not.toHaveBeenCalled()
})
})
describe('warm profile cache', () => {
it('should sync identity when the profile query is already cached', async () => {
const store = createStore()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
},
},
})
queryClient.setQueryData(['user-profile'], createProfileQueryData())
store.set(queryClientAtom, queryClient)
store.sub(amplitudeIdentitySyncAtom, () => {})
await vi.waitFor(() => {
expect(setUserId).toHaveBeenCalledWith('user@example.com')
})
})
it('should not re-sync identity when the effect re-runs with the same profile', async () => {
const store = createStore()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
},
},
})
queryClient.setQueryData(['user-profile'], createProfileQueryData())
queryClient.setQueryData(['current-workspace'], mockCurrentWorkspaceResponse)
store.set(queryClientAtom, queryClient)
store.sub(amplitudeIdentitySyncAtom, () => {})
await vi.waitFor(() => {
expect(setUserProperties).toHaveBeenCalledWith(
expect.objectContaining({ workspace_id: 'workspace-1' }),
)
})
await flushAsync()
const settledCallCount = vi.mocked(setUserId).mock.calls.length
queryClient.setQueryData(
['user-profile'],
createProfileQueryData({ avatar: 'changed-avatar' }),
)
await flushAsync()
expect(setUserId).toHaveBeenCalledTimes(settledCallCount)
expect(setUserProperties).toHaveBeenCalledTimes(settledCallCount)
})
})
})
@@ -0,0 +1,233 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import { QueryClient } from '@tanstack/react-query'
import { createStore } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { zendeskConversationSyncAtom } from '../zendesk-conversation-sync'
type ProfileQueryData = {
profile: GetAccountProfileResponse
meta: {
currentVersion: string | null
currentEnv: string | null
}
}
const profileQueryState = vi.hoisted(() => {
const state = {
resolve: (_value: unknown) => {},
queryFn: () =>
new Promise<unknown>((resolve) => {
state.resolve = resolve
}),
}
return state
})
const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({
isPending: true,
}))
const mockCurrentWorkspaceResponse = vi.hoisted(() => ({
id: 'workspace-1',
name: 'Workspace',
plan: 'sandbox',
status: 'normal',
created_at: 1704067200,
role: 'editor',
trial_credits: 200,
trial_credits_used: 0,
next_credit_reset_date: 1706745600,
custom_config: {},
}))
const mockSystemFeaturesState = vi.hoisted(() => ({
data: {
branding: {
enabled: false,
},
},
}))
const mockLangGeniusVersionState = vi.hoisted(() => ({
data: {
version: '1.0.1',
release_date: '',
release_notes: '',
features: {
can_replace_logo: false,
model_load_balancing_enabled: false,
},
can_auto_update: false,
},
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
ZENDESK_FIELD_IDS: {
ENVIRONMENT: 'environment-field',
VERSION: 'version-field',
EMAIL: 'email-field',
WORKSPACE_ID: 'workspace-id-field',
},
}
})
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['user-profile'],
queryFn: profileQueryState.queryFn,
}),
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: async () => mockSystemFeaturesState.data,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
workspaces: {
current: {
post: {
key: () => ['current-workspace'],
queryOptions: (options: {
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
}) => ({
queryKey: ['current-workspace'],
queryFn: async () => {
if (mockCurrentWorkspaceQueryState.isPending) return new Promise(() => {})
return mockCurrentWorkspaceResponse
},
...options,
}),
},
},
},
version: {
get: {
queryOptions: (options: {
enabled?: boolean
input?: {
query: {
current_version: string
}
}
}) => ({
queryKey: ['version', options.input?.query.current_version],
queryFn: async () => mockLangGeniusVersionState.data,
...options,
}),
},
},
},
}))
vi.mock('@/app/components/base/zendesk/utils', () => ({
setZendeskConversationFields: vi.fn(),
}))
function createProfileQueryData(): ProfileQueryData {
return {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
} as GetAccountProfileResponse,
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
}
function createTestStore() {
const store = createStore()
store.set(
queryClientAtom,
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
}),
)
return store
}
async function flushAsync() {
await new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
// Regression for langgenius/dify#38840: reading resolved-suspense atoms throws while pending.
describe('zendeskConversationSyncAtom', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspaceQueryState.isPending = true
})
describe('cold caches', () => {
it('should not throw and should skip all field syncs while queries are pending', async () => {
const store = createTestStore()
expect(() => store.sub(zendeskConversationSyncAtom, () => {})).not.toThrow()
await flushAsync()
expect(setZendeskConversationFields).not.toHaveBeenCalled()
})
it('should sync all fields when the pending queries resolve', async () => {
mockCurrentWorkspaceQueryState.isPending = false
const store = createTestStore()
store.sub(zendeskConversationSyncAtom, () => {})
await flushAsync()
profileQueryState.resolve(createProfileQueryData())
await vi.waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([
{
id: ZENDESK_FIELD_IDS.EMAIL,
value: 'user@example.com',
},
])
})
await vi.waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([
{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: 'cloud',
},
])
})
await vi.waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([
{
id: ZENDESK_FIELD_IDS.VERSION,
value: '1.0.1',
},
])
})
await vi.waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([
{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: 'workspace-1',
},
])
})
})
})
})
+13 -1
View File
@@ -1,12 +1,15 @@
'use client'
import { atom } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { atomWithResolvedSuspenseQuery } from '@/utils/query-atoms'
const accountProfileQueryAtom = atomWithResolvedSuspenseQuery(() => userProfileQueryOptions())
const accountProfilePendingQueryAtom = atomWithQuery(() => userProfileQueryOptions())
/** Render-path only — throws while pending; effects use `userProfileOrNullAtom`. */
export const userProfileAtom = atom((get) => {
return get(accountProfileQueryAtom).data.profile
})
@@ -19,10 +22,19 @@ export const userProfileEmailAtom = atom((get) => {
return get(userProfileAtom).email
})
/** Render-path only — throws while pending; effects use `accountProfileMetaOrNullAtom`. */
export const accountProfileMetaAtom = atom((get) => {
return get(accountProfileQueryAtom).data.meta
})
export const userProfileOrNullAtom = atom((get) => {
return get(accountProfilePendingQueryAtom).data?.profile ?? null
})
export const accountProfileMetaOrNullAtom = atom((get) => {
return get(accountProfilePendingQueryAtom).data?.meta ?? null
})
export const refreshUserProfileAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
+3 -3
View File
@@ -6,7 +6,7 @@ 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 { userProfileAtom } from './account-state'
import { userProfileOrNullAtom } from './account-state'
import { currentWorkspaceAtom } from './workspace-state'
type AmplitudeProperties = Record<string, string | number | boolean>
@@ -38,10 +38,10 @@ function buildAmplitudeProperties({
}
export const amplitudeIdentitySyncAtom = atomEffect((get, set) => {
const userProfile = get(userProfileAtom)
const userProfile = get(userProfileOrNullAtom)
const currentWorkspace = get(currentWorkspaceAtom)
if (!userProfile.id) return
if (!userProfile?.id) return
const properties = buildAmplitudeProperties({
currentWorkspace,
+9 -1
View File
@@ -1,15 +1,23 @@
'use client'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { atomWithResolvedSuspenseQuery } from '@/utils/query-atoms'
const systemFeaturesQueryAtom = atomWithResolvedSuspenseQuery(() => systemFeaturesQueryOptions())
export const systemFeaturesAtom = atom((get) => {
const systemFeaturesPendingQueryAtom = atomWithQuery(() => systemFeaturesQueryOptions())
/** Render-path only — throws while pending; effects use `systemFeaturesOrNullAtom`. */
const systemFeaturesAtom = atom((get) => {
return get(systemFeaturesQueryAtom).data
})
export const systemFeaturesOrNullAtom = atom((get) => {
return get(systemFeaturesPendingQueryAtom).data ?? null
})
export const datasetRbacEnabledAtom = atom((get) => {
return get(systemFeaturesAtom).rbac_enabled
})
+21 -6
View File
@@ -3,26 +3,29 @@
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { accountProfileMetaAtom } from './account-state'
import { accountProfileMetaAtom, accountProfileMetaOrNullAtom } from './account-state'
import { initialLangGeniusVersionInfo } from './app-context-defaults'
import { getLangGeniusVersionInfo } from './app-context-normalizers'
import { systemFeaturesAtom } from './system-features-state'
import { systemFeaturesOrNullAtom } from './system-features-state'
const versionQueryAtom = atomWithQuery((get) => {
const meta = get(accountProfileMetaAtom)
const systemFeatures = get(systemFeaturesAtom)
const enabled = Boolean(meta.currentVersion && !systemFeatures.branding.enabled)
const meta = get(accountProfileMetaOrNullAtom)
const systemFeatures = get(systemFeaturesOrNullAtom)
const enabled = Boolean(
meta?.currentVersion && systemFeatures && !systemFeatures.branding.enabled,
)
return consoleQuery.version.get.queryOptions({
input: {
query: {
current_version: meta.currentVersion ?? '',
current_version: meta?.currentVersion ?? '',
},
},
enabled,
})
})
/** Render-path only — throws while pending; effects use `langGeniusVersionInfoOrDefaultAtom`. */
export const langGeniusVersionInfoAtom = atom((get) => {
const meta = get(accountProfileMetaAtom)
const versionData = get(versionQueryAtom).data
@@ -35,6 +38,18 @@ export const langGeniusVersionInfoAtom = atom((get) => {
})
})
export const langGeniusVersionInfoOrDefaultAtom = atom((get) => {
const meta = get(accountProfileMetaOrNullAtom)
const versionData = get(versionQueryAtom).data
if (!meta || !versionData) return initialLangGeniusVersionInfo
return getLangGeniusVersionInfo({
meta,
versionData,
})
})
export const langGeniusCurrentVersionAtom = atom((get) => {
return get(langGeniusVersionInfoAtom).current_version
})
+5 -5
View File
@@ -4,8 +4,8 @@ import { atom } from 'jotai'
import { atomEffect } from 'jotai-effect'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { userProfileAtom } from './account-state'
import { langGeniusVersionInfoAtom } from './version-state'
import { userProfileOrNullAtom } from './account-state'
import { langGeniusVersionInfoOrDefaultAtom } from './version-state'
import { currentWorkspaceAtom } from './workspace-state'
type ZendeskSyncState = {
@@ -42,9 +42,9 @@ function syncZendeskField({
}
export const zendeskConversationSyncAtom = atomEffect((get, set) => {
const userProfile = get(userProfileAtom)
const userProfile = get(userProfileOrNullAtom)
const currentWorkspace = get(currentWorkspaceAtom)
const langGeniusVersionInfo = get(langGeniusVersionInfoAtom)
const langGeniusVersionInfo = get(langGeniusVersionInfoOrDefaultAtom)
const state = get.peek(zendeskConversationSyncStateAtom)
const nextState = { ...state }
@@ -70,7 +70,7 @@ export const zendeskConversationSyncAtom = atomEffect((get, set) => {
didSync =
syncZendeskField({
fieldId: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
value: userProfile?.email ?? '',
previousValue: state.email,
setNextValue: (value) => {
nextState.email = value