diff --git a/web/app/(commonLayout)/__tests__/console-bootstrap-gate.spec.tsx b/web/app/(commonLayout)/__tests__/console-bootstrap-gate.spec.tsx new file mode 100644 index 00000000000..7656aac6d18 --- /dev/null +++ b/web/app/(commonLayout)/__tests__/console-bootstrap-gate.spec.tsx @@ -0,0 +1,113 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConsoleBootstrapGate } from '../console-bootstrap-gate' + +const profileQueryKey = ['console', 'account', 'profile', 'get'] +const systemFeaturesQueryKey = ['console', 'system-features', 'get'] + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + + return { promise, resolve, reject } +} + +const mocks = vi.hoisted(() => ({ + profileQuery: undefined as Deferred<{ id: string }> | undefined, + systemFeaturesQuery: undefined as Deferred<{ branding: { enabled: boolean } }> | undefined, +})) + +vi.mock('@/features/account-profile/client', () => ({ + userProfileQueryOptions: () => ({ + queryKey: profileQueryKey, + queryFn: () => mocks.profileQuery!.promise, + }), +})) + +vi.mock('@/features/system-features/client', () => ({ + systemFeaturesQueryOptions: () => ({ + queryKey: systemFeaturesQueryKey, + queryFn: () => mocks.systemFeaturesQuery!.promise, + }), +})) + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) +} + +function renderGate(children: ReactNode, queryClient = createQueryClient()) { + render( + + {children} + , + ) + + return queryClient +} + +describe('ConsoleBootstrapGate', () => { + beforeEach(() => { + mocks.profileQuery = createDeferred() + mocks.systemFeaturesQuery = createDeferred() + }) + + it('waits for profile and system features before mounting atom consumers', async () => { + renderGate(
Console shell
) + + expect(screen.queryByText('Console shell')).not.toBeInTheDocument() + + await act(async () => { + mocks.profileQuery!.resolve({ id: 'user-1' }) + }) + expect(screen.queryByText('Console shell')).not.toBeInTheDocument() + + await act(async () => { + mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } }) + }) + + expect(await screen.findByText('Console shell')).toBeInTheDocument() + }) + + it('keeps atom consumers mounted when a cached profile background refetch fails', async () => { + const queryClient = createQueryClient() + queryClient.setQueryData(profileQueryKey, { id: 'user-1' }, { updatedAt: 1 }) + queryClient.setQueryData( + systemFeaturesQueryKey, + { branding: { enabled: false } }, + { updatedAt: 1 }, + ) + + renderGate(
Console shell
, queryClient) + + expect(screen.getByText('Console shell')).toBeInTheDocument() + await waitFor(() => { + expect(queryClient.getQueryState(profileQueryKey)?.fetchStatus).toBe('fetching') + }) + + await act(async () => { + mocks.profileQuery!.reject(new Error('profile refetch failed')) + mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } }) + }) + + await waitFor(() => { + expect(queryClient.getQueryState(profileQueryKey)?.status).toBe('error') + }) + expect(screen.getByText('Console shell')).toBeInTheDocument() + }) +}) diff --git a/web/app/(commonLayout)/console-bootstrap-gate.tsx b/web/app/(commonLayout)/console-bootstrap-gate.tsx new file mode 100644 index 00000000000..d507eee82d2 --- /dev/null +++ b/web/app/(commonLayout)/console-bootstrap-gate.tsx @@ -0,0 +1,14 @@ +'use client' + +import type { ReactNode } from 'react' +import { useSuspenseQueries } from '@tanstack/react-query' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' + +export function ConsoleBootstrapGate({ children }: { children: ReactNode }) { + useSuspenseQueries({ + queries: [userProfileQueryOptions(), systemFeaturesQueryOptions()], + }) + + return children +} diff --git a/web/app/(commonLayout)/providers.tsx b/web/app/(commonLayout)/providers.tsx index 28140d0db84..92d856ca088 100644 --- a/web/app/(commonLayout)/providers.tsx +++ b/web/app/(commonLayout)/providers.tsx @@ -6,6 +6,7 @@ import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration- import { EventEmitterContextProvider } from '@/context/event-emitter-provider' import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' +import { ConsoleBootstrapGate } from './console-bootstrap-gate' import { ExternalServiceSync } from './external-service-sync' import { CommonLayoutHydrationBoundary } from './hydration-boundary' @@ -17,8 +18,10 @@ export async function ConsoleRuntimeProviders({ children }: { children: ReactNod - - {children} + + + {children} + )