refactor(web): setup status caching (#30798)

This commit is contained in:
yyh 2026-01-13 16:59:49 +08:00 committed by GitHub
parent 491e1fd6a4
commit 1fbdf6b465
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 229 additions and 39 deletions

View File

@ -53,6 +53,7 @@ vi.mock('@/context/global-public-context', () => {
) )
return { return {
useGlobalPublicStore, useGlobalPublicStore,
useIsSystemFeaturesPending: () => false,
} }
}) })

View File

@ -9,8 +9,8 @@ import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants' } from '@/app/education-apply/constants'
import { fetchSetupStatus } from '@/service/common'
import { sendGAEvent } from '@/utils/gtag' import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect' import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude' import { trackEvent } from './base/amplitude'
@ -33,15 +33,8 @@ export const AppInitializer = ({
const isSetupFinished = useCallback(async () => { const isSetupFinished = useCallback(async () => {
try { try {
if (localStorage.getItem('setup_status') === 'finished') const setUpStatus = await fetchSetupStatusWithCache()
return true return setUpStatus.step === 'finished'
const setUpStatus = await fetchSetupStatus()
if (setUpStatus.step !== 'finished') {
localStorage.removeItem('setup_status')
return false
}
localStorage.setItem('setup_status', 'finished')
return true
} }
catch (error) { catch (error) {
console.error(error) console.error(error)

View File

@ -125,7 +125,6 @@ const resetAccessControlStore = () => {
const resetGlobalStore = () => { const resetGlobalStore = () => {
useGlobalPublicStore.setState({ useGlobalPublicStore.setState({
systemFeatures: defaultSystemFeatures, systemFeatures: defaultSystemFeatures,
isGlobalPending: false,
}) })
} }

View File

@ -19,6 +19,14 @@ vi.mock('@/service/common', () => ({
getSystemFeatures: vi.fn(), getSystemFeatures: vi.fn(),
})) }))
vi.mock('@/context/global-public-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/global-public-context')>()
return {
...actual,
useIsSystemFeaturesPending: () => false,
}
})
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus) const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus) const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
const mockSetup = vi.mocked(setup) const mockSetup = vi.mocked(setup)

View File

@ -2,42 +2,61 @@
import type { FC, PropsWithChildren } from 'react' import type { FC, PropsWithChildren } from 'react'
import type { SystemFeatures } from '@/types/feature' import type { SystemFeatures } from '@/types/feature'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { getSystemFeatures } from '@/service/common' import { getSystemFeatures } from '@/service/common'
import { defaultSystemFeatures } from '@/types/feature' import { defaultSystemFeatures } from '@/types/feature'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
type GlobalPublicStore = { type GlobalPublicStore = {
isGlobalPending: boolean
setIsGlobalPending: (isPending: boolean) => void
systemFeatures: SystemFeatures systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void setSystemFeatures: (systemFeatures: SystemFeatures) => void
} }
export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({ export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
isGlobalPending: true,
setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })),
systemFeatures: defaultSystemFeatures, systemFeatures: defaultSystemFeatures,
setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })), setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })),
})) }))
const systemFeaturesQueryKey = ['systemFeatures'] as const
const setupStatusQueryKey = ['setupStatus'] as const
async function fetchSystemFeatures() {
const data = await getSystemFeatures()
const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data })
return data
}
export function useSystemFeaturesQuery() {
return useQuery({
queryKey: systemFeaturesQueryKey,
queryFn: fetchSystemFeatures,
})
}
export function useIsSystemFeaturesPending() {
const { isPending } = useSystemFeaturesQuery()
return isPending
}
export function useSetupStatusQuery() {
return useQuery({
queryKey: setupStatusQueryKey,
queryFn: fetchSetupStatusWithCache,
staleTime: Infinity,
})
}
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({ const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
children, children,
}) => { }) => {
const { isPending, data } = useQuery({ // Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
queryKey: ['systemFeatures'], // setupStatus is prefetched here and cached in localStorage for AppInitializer.
queryFn: getSystemFeatures, const { isPending } = useSystemFeaturesQuery()
})
const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore()
useEffect(() => {
if (data)
setSystemFeatures({ ...defaultSystemFeatures, ...data })
}, [data, setSystemFeatures])
useEffect(() => { // Prefetch setupStatus for AppInitializer (result not needed here)
setIsPending(isPending) useSetupStatusQuery()
}, [isPending, setIsPending])
if (isPending) if (isPending)
return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div> return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>

View File

@ -10,7 +10,7 @@ import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
import { useGetWebAppAccessModeByCode } from '@/service/use-share' import { useGetWebAppAccessModeByCode } from '@/service/use-share'
import { useGlobalPublicStore } from './global-public-context' import { useIsSystemFeaturesPending } from './global-public-context'
type WebAppStore = { type WebAppStore = {
shareCode: string | null shareCode: string | null
@ -65,7 +65,7 @@ const getShareCodeFromPathname = (pathname: string): string | null => {
} }
const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => { const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) const isGlobalPending = useIsSystemFeaturesPending()
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode) const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
const updateShareCode = useWebAppStore(state => state.updateShareCode) const updateShareCode = useWebAppStore(state => state.updateShareCode)
const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId) const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId)

View File

@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react' import { act, renderHook } from '@testing-library/react'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore, useIsSystemFeaturesPending } from '@/context/global-public-context'
/** /**
* Test suite for useDocumentTitle hook * Test suite for useDocumentTitle hook
* *
@ -15,6 +15,14 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { defaultSystemFeatures } from '@/types/feature' import { defaultSystemFeatures } from '@/types/feature'
import useDocumentTitle from './use-document-title' import useDocumentTitle from './use-document-title'
vi.mock('@/context/global-public-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/global-public-context')>()
return {
...actual,
useIsSystemFeaturesPending: vi.fn(() => false),
}
})
vi.mock('@/service/common', () => ({ vi.mock('@/service/common', () => ({
getSystemFeatures: vi.fn(() => ({ ...defaultSystemFeatures })), getSystemFeatures: vi.fn(() => ({ ...defaultSystemFeatures })),
})) }))
@ -24,10 +32,12 @@ vi.mock('@/service/common', () => ({
* Title should remain empty to prevent flicker * Title should remain empty to prevent flicker
*/ */
describe('title should be empty if systemFeatures is pending', () => { describe('title should be empty if systemFeatures is pending', () => {
act(() => { beforeEach(() => {
useGlobalPublicStore.setState({ vi.mocked(useIsSystemFeaturesPending).mockReturnValue(true)
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, act(() => {
isGlobalPending: true, useGlobalPublicStore.setState({
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
})
}) })
}) })
/** /**
@ -52,9 +62,9 @@ describe('title should be empty if systemFeatures is pending', () => {
*/ */
describe('use default branding', () => { describe('use default branding', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(false)
act(() => { act(() => {
useGlobalPublicStore.setState({ useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
}) })
}) })
@ -84,9 +94,9 @@ describe('use default branding', () => {
*/ */
describe('use specific branding', () => { describe('use specific branding', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(false)
act(() => { act(() => {
useGlobalPublicStore.setState({ useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } }, systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } },
}) })
}) })

View File

@ -1,11 +1,11 @@
'use client' 'use client'
import { useFavicon, useTitle } from 'ahooks' import { useFavicon, useTitle } from 'ahooks'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore, useIsSystemFeaturesPending } from '@/context/global-public-context'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
export default function useDocumentTitle(title: string) { export default function useDocumentTitle(title: string) {
const isPending = useGlobalPublicStore(s => s.isGlobalPending) const isPending = useIsSystemFeaturesPending()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const prefix = title ? `${title} - ` : '' const prefix = title ? `${title} - ` : ''
let titleStr = '' let titleStr = ''

View File

@ -0,0 +1,139 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
import { fetchSetupStatusWithCache } from './setup-status'
vi.mock('@/service/common', () => ({
fetchSetupStatus: vi.fn(),
}))
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
describe('setup-status utilities', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
describe('fetchSetupStatusWithCache', () => {
describe('when cache exists', () => {
it('should return cached finished status without API call', async () => {
localStorage.setItem('setup_status', 'finished')
const result = await fetchSetupStatusWithCache()
expect(result).toEqual({ step: 'finished' })
expect(mockFetchSetupStatus).not.toHaveBeenCalled()
})
it('should not modify localStorage when returning cached value', async () => {
localStorage.setItem('setup_status', 'finished')
await fetchSetupStatusWithCache()
expect(localStorage.getItem('setup_status')).toBe('finished')
})
})
describe('when cache does not exist', () => {
it('should call API and cache finished status', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBe('finished')
})
it('should call API and remove cache when not finished', async () => {
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
it('should clear stale cache when API returns not_started', async () => {
localStorage.setItem('setup_status', 'some_invalid_value')
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
describe('cache edge cases', () => {
it('should call API when cache value is empty string', async () => {
localStorage.setItem('setup_status', '')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when cache value is not "finished"', async () => {
localStorage.setItem('setup_status', 'not_started')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when localStorage key does not exist', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
})
describe('API response handling', () => {
it('should preserve setup_at from API response', async () => {
const setupDate = new Date('2024-01-01')
const apiResponse: SetupStatusResponse = {
step: 'finished',
setup_at: setupDate,
}
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(result.setup_at).toEqual(setupDate)
})
it('should propagate API errors', async () => {
const apiError = new Error('Network error')
mockFetchSetupStatus.mockRejectedValue(apiError)
await expect(fetchSetupStatusWithCache()).rejects.toThrow('Network error')
})
it('should not update cache when API call fails', async () => {
mockFetchSetupStatus.mockRejectedValue(new Error('API error'))
await expect(fetchSetupStatusWithCache()).rejects.toThrow()
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
})
})

21
web/utils/setup-status.ts Normal file
View File

@ -0,0 +1,21 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
const SETUP_STATUS_KEY = 'setup_status'
const isSetupStatusCached = (): boolean =>
localStorage.getItem(SETUP_STATUS_KEY) === 'finished'
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
if (isSetupStatusCached())
return { step: 'finished' }
const status = await fetchSetupStatus()
if (status.step === 'finished')
localStorage.setItem(SETUP_STATUS_KEY, 'finished')
else
localStorage.removeItem(SETUP_STATUS_KEY)
return status
}