fix(web): prevent false 404 during installed app refetch

- keep loading state when installed app list is refetching without a matched id

- align installed-app tests with TanStack Query isPending semantics

- add refetch regression assertions to avoid early 404 fallback
This commit is contained in:
yyh
2026-02-14 14:14:51 +08:00
parent 8e7eb645d9
commit d00367c7c9
3 changed files with 59 additions and 34 deletions

View File

@ -80,17 +80,20 @@ describe('Installed App Flow', () => {
}
type MockOverrides = {
installedApps?: { apps?: InstalledAppModel[], isPending?: boolean }
accessMode?: { isLoading?: boolean, data?: unknown, error?: unknown }
params?: { isLoading?: boolean, data?: unknown, error?: unknown }
meta?: { isLoading?: boolean, data?: unknown, error?: unknown }
installedApps?: { apps?: InstalledAppModel[], isPending?: boolean, isFetching?: boolean }
accessMode?: { isPending?: boolean, data?: unknown, error?: unknown }
params?: { isPending?: boolean, data?: unknown, error?: unknown }
meta?: { isPending?: boolean, data?: unknown, error?: unknown }
userAccess?: { data?: unknown, error?: unknown }
}
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
const installedApps = overrides.installedApps?.apps ?? (app ? [app] : [])
;(useGetInstalledApps as Mock).mockReturnValue({
data: { installed_apps: app ? [app] : [] },
data: { installed_apps: installedApps },
isPending: false,
isFetching: false,
...overrides.installedApps,
})
@ -105,21 +108,21 @@ describe('Installed App Flow', () => {
})
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: { accessMode: AccessMode.PUBLIC },
error: null,
...overrides.accessMode,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: mockAppParams,
error: null,
...overrides.params,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: { tool_icons: {} },
error: null,
...overrides.meta,
@ -176,7 +179,7 @@ describe('Installed App Flow', () => {
describe('Data Loading Flow', () => {
it('should show loading spinner when params are being fetched', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { params: { isLoading: true, data: null } })
setupDefaultMocks(app, { params: { isPending: true, data: null } })
const { container } = render(<InstalledApp id="installed-app-1" />)
@ -184,6 +187,17 @@ describe('Installed App Flow', () => {
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
})
it('should defer 404 while installed apps are refetching without a match', () => {
setupDefaultMocks(undefined, {
installedApps: { apps: [], isPending: false, isFetching: true },
})
const { container } = render(<InstalledApp id="nonexistent" />)
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should render content when all data is available', () => {
const app = createInstalledApp()
setupDefaultMocks(app)

View File

@ -91,10 +91,22 @@ describe('InstalledApp', () => {
result: true,
}
const setupMocks = (installedApps: InstalledAppType[] = [mockInstalledApp], isPending = false) => {
const setupMocks = (
installedApps: InstalledAppType[] = [mockInstalledApp],
options: {
isPending?: boolean
isFetching?: boolean
} = {},
) => {
const {
isPending = false,
isFetching = false,
} = options
;(useGetInstalledApps as Mock).mockReturnValue({
data: { installed_apps: installedApps },
isPending,
isFetching,
})
}
@ -123,19 +135,19 @@ describe('InstalledApp', () => {
})
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: mockWebAppAccessMode,
error: null,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: mockAppParams,
error: null,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: mockAppMeta,
error: null,
})
@ -154,7 +166,7 @@ describe('InstalledApp', () => {
it('should render loading state when fetching app params', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: true,
isPending: true,
data: null,
error: null,
})
@ -166,7 +178,7 @@ describe('InstalledApp', () => {
it('should render loading state when fetching app meta', () => {
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isLoading: true,
isPending: true,
data: null,
error: null,
})
@ -178,7 +190,7 @@ describe('InstalledApp', () => {
it('should render loading state when fetching web app access mode', () => {
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isLoading: true,
isPending: true,
data: null,
error: null,
})
@ -189,7 +201,7 @@ describe('InstalledApp', () => {
})
it('should render loading state when fetching installed apps', () => {
setupMocks([mockInstalledApp], true)
setupMocks([mockInstalledApp], { isPending: true })
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
@ -208,7 +220,7 @@ describe('InstalledApp', () => {
it('should render error when app params fails to load', () => {
const error = new Error('Failed to load app params')
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: null,
error,
})
@ -220,7 +232,7 @@ describe('InstalledApp', () => {
it('should render error when app meta fails to load', () => {
const error = new Error('Failed to load app meta')
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: null,
error,
})
@ -232,7 +244,7 @@ describe('InstalledApp', () => {
it('should render error when web app access mode fails to load', () => {
const error = new Error('Failed to load access mode')
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: null,
error,
})
@ -444,7 +456,7 @@ describe('InstalledApp', () => {
it('should not update app params when data is null', async () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: null,
error: null,
})
@ -460,7 +472,7 @@ describe('InstalledApp', () => {
it('should not update app meta when data is null', async () => {
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: null,
error: null,
})
@ -476,7 +488,7 @@ describe('InstalledApp', () => {
it('should not update access mode when data is null', async () => {
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: null,
error: null,
})
@ -557,7 +569,7 @@ describe('InstalledApp', () => {
describe('Render Priority', () => {
it('should show error before loading state', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: true,
isPending: true,
data: null,
error: new Error('Some error'),
})
@ -568,7 +580,7 @@ describe('InstalledApp', () => {
it('should show error before permission check', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: false,
isPending: false,
data: null,
error: new Error('Params error'),
})
@ -594,13 +606,8 @@ describe('InstalledApp', () => {
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should show loading before 404', () => {
setupMocks([])
;(useGetInstalledAppParams as Mock).mockReturnValue({
isLoading: true,
data: null,
error: null,
})
it('should show loading before 404 while installed apps are refetching', () => {
setupMocks([], { isFetching: true })
const { container } = render(<InstalledApp id="nonexistent-app" />)
const svg = container.querySelector('svg.spin-animation')

View File

@ -17,7 +17,7 @@ const InstalledApp = ({
}: {
id: string
}) => {
const { data, isPending: isPendingInstalledApps } = useGetInstalledApps()
const { data, isPending: isPendingInstalledApps, isFetching: isFetchingInstalledApps } = useGetInstalledApps()
const installedApp = data?.installed_apps?.find(item => item.id === id)
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode)
@ -97,7 +97,11 @@ const InstalledApp = ({
</div>
)
}
if (isPendingInstalledApps || (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))) {
if (
isPendingInstalledApps
|| (!installedApp && isFetchingInstalledApps)
|| (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))
) {
return (
<div className="flex h-full items-center justify-center">
<Loading />