diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceContext.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceContext.tsx index 58a744242b883..315501af8ff19 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceContext.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/WorkspaceContext.tsx @@ -1,5 +1,13 @@ import sortBy from 'lodash/sortBy'; -import React, {useCallback, useContext, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import {useSetRecoilState} from 'recoil'; import {CODE_LOCATION_STATUS_QUERY, LOCATION_WORKSPACE_QUERY} from './WorkspaceQueries'; @@ -22,11 +30,10 @@ import { repoLocationToRepos, useVisibleRepos, } from './util'; -import {useApolloClient} from '../../apollo-client'; +import {ApolloClient, useApolloClient} from '../../apollo-client'; import {AppContext} from '../../app/AppContext'; import {useRefreshAtInterval} from '../../app/QueryRefresh'; import {PythonErrorFragment} from '../../app/types/PythonErrorFragment.types'; -import {useUpdatingRef} from '../../hooks/useUpdatingRef'; import {codeLocationStatusAtom} from '../../nav/useCodeLocationsStatus'; import { useClearCachedData, @@ -36,12 +43,13 @@ import { } from '../../search/useIndexedDBCachedQuery'; export const CODE_LOCATION_STATUS_QUERY_KEY = '/CodeLocationStatusQuery'; +export const HIDDEN_REPO_KEYS = 'dagster.hidden-repo-keys'; export type WorkspaceRepositorySensor = WorkspaceSensorFragment; export type WorkspaceRepositorySchedule = WorkspaceScheduleFragment; export type WorkspaceRepositoryLocationNode = WorkspaceLocationNodeFragment; -type WorkspaceState = { +interface WorkspaceState { loading: boolean; locationEntries: WorkspaceRepositoryLocationNode[]; locationStatuses: Record; @@ -49,258 +57,143 @@ type WorkspaceState = { visibleRepos: DagsterRepoOption[]; data: Record; refetch: () => Promise; - toggleVisible: SetVisibleOrHiddenFn; setVisible: SetVisibleOrHiddenFn; setHidden: SetVisibleOrHiddenFn; -}; +} export const WorkspaceContext = React.createContext( new Error('WorkspaceContext should never be uninitialized') as any, ); -export const HIDDEN_REPO_KEYS = 'dagster.hidden-repo-keys'; - -export const WorkspaceProvider = ({children}: {children: React.ReactNode}) => { - const {localCacheIdPrefix} = useContext(AppContext); - const codeLocationStatusQueryResult = useIndexedDBCachedQuery< - CodeLocationStatusQuery, - CodeLocationStatusQueryVariables - >({ - query: CODE_LOCATION_STATUS_QUERY, - version: CodeLocationStatusQueryVersion, - key: `${localCacheIdPrefix}${CODE_LOCATION_STATUS_QUERY_KEY}`, - }); - if (typeof jest === 'undefined') { - // Only do this outside of jest for now so that we don't need to add RecoilRoot around everything... - // we will switch to jotai at some point instead... which doesnt require a - // eslint-disable-next-line react-hooks/rules-of-hooks - const setCodeLocationStatusAtom = useSetRecoilState(codeLocationStatusAtom); - // eslint-disable-next-line react-hooks/rules-of-hooks - useLayoutEffect(() => { - if (codeLocationStatusQueryResult.data) { - setCodeLocationStatusAtom(codeLocationStatusQueryResult.data); - } - }, [codeLocationStatusQueryResult.data, setCodeLocationStatusAtom]); - } - indexedDB.deleteDatabase('indexdbQueryCache:RootWorkspace'); - - const fetch = codeLocationStatusQueryResult.fetch; - useRefreshAtInterval({ - refresh: useCallback(async () => { - return await fetch(); - }, [fetch]), - intervalMs: 5000, - leading: true, - }); +interface BaseLocationParams { + localCacheIdPrefix?: string; + getCachedData: ReturnType; + getData: ReturnType; + clearCachedData: ReturnType; + client: ApolloClient; +} - const {data: codeLocationStatusData} = codeLocationStatusQueryResult; +interface LoadCachedLocationDataParams + extends Pick { + previousLocationVersionsRef: React.MutableRefObject>; +} - const locationStatuses = useMemo( - () => getLocations(codeLocationStatusData), - [codeLocationStatusData], - ); - const prevLocationStatuses = useRef({}); +interface RefreshLocationsIfNeededParams + extends Omit { + locationStatuses: Record; + locationEntryData: Record; + setLocationEntryData: React.Dispatch< + React.SetStateAction> + >; + previousLocationVersionsRef: React.MutableRefObject>; +} - const didInitiateFetchFromCache = useRef(false); - const [didLoadStatusData, setDidLoadStatusData] = useState(false); +interface HandleDeletedLocationsParams + extends Pick { + locationStatuses: Record; + setLocationEntryData: React.Dispatch< + React.SetStateAction> + >; + previousLocationVersionsRef: React.MutableRefObject>; +} - const [locationEntriesData, setLocationEntriesData] = React.useState< - Record - >({}); +interface FetchLocationDataParams + extends Pick { + name: string; + setLocationEntryData: React.Dispatch< + React.SetStateAction> + >; +} +export const WorkspaceProvider = ({children}: {children: React.ReactNode}) => { + const {localCacheIdPrefix} = useContext(AppContext); + const client = useApolloClient(); const getCachedData = useGetCachedData(); - const getData = useGetData(); const clearCachedData = useClearCachedData(); + const getData = useGetData(); - useLayoutEffect(() => { - // Load data from the cache - if (didInitiateFetchFromCache.current) { - return; - } - didInitiateFetchFromCache.current = true; - const allData: typeof locationEntriesData = {}; - new Promise(async (res) => { - /** - * 1. Load the cached code location status query - * 2. Load the cached data for those locations - * 3. Set the cached data to `locationsData` state - * 4. Set prevLocations equal to these cached locations so that we can check if they - * have changed after the next call to codeLocationStatusQuery - * 5. set didLoadCachedData to true to unblock the `locationsToFetch` memo so that it can compare - * the latest codeLocationStatusQuery result to what was in the cache. - */ - const data = await getCachedData({ - key: `${localCacheIdPrefix}${CODE_LOCATION_STATUS_QUERY_KEY}`, - version: CodeLocationStatusQueryVersion, - }); - const cachedLocations = getLocations(data); - const prevCachedLocations: typeof locationStatuses = {}; - - await Promise.all([ - ...Object.values(cachedLocations).map(async (location) => { - const locationData = await getCachedData({ - key: `${localCacheIdPrefix}${locationWorkspaceKey(location.name)}`, - version: LocationWorkspaceQueryVersion, - }); - const entry = locationData?.workspaceLocationEntryOrError; - if (!entry) { - return; - } - allData[location.name] = entry; - - if (entry.__typename === 'WorkspaceLocationEntry') { - prevCachedLocations[location.name] = location; - } - }), - ]); - prevLocationStatuses.current = prevCachedLocations; - res(void 0); - }).then(() => { - setDidLoadStatusData(true); - setLocationEntriesData(allData); - }); - }, [getCachedData, localCacheIdPrefix, locationStatuses]); - - const client = useApolloClient(); + const [locationEntryData, setLocationEntryData] = useState< + Record + >({}); - const refetchLocation = useCallback( - async (name: string) => { - const locationData = await getData({ - client, - query: LOCATION_WORKSPACE_QUERY, - key: `${localCacheIdPrefix}${locationWorkspaceKey(name)}`, - version: LocationWorkspaceQueryVersion, - variables: { - name, - }, - bypassCache: true, - }); - const entry = locationData.data?.workspaceLocationEntryOrError; - if (entry) { - setLocationEntriesData((locationsData) => - Object.assign({}, locationsData, { - [name]: entry, - }), - ); - } - return locationData; - }, - [client, getData, localCacheIdPrefix], - ); + const previousLocationVersionsRef = useRef>({}); - const [isRefetching, setIsRefetching] = useState(false); + const {locationStatuses, loading: loadingLocationStatuses} = + useCodeLocationStatuses(localCacheIdPrefix); - const locationsToFetch = useMemo(() => { - if (!didLoadStatusData) { - return []; - } - if (isRefetching) { - return []; - } - const toFetch = Object.values(locationStatuses).filter((statusEntry) => { - const d = locationEntriesData[statusEntry.name]; - const locationEntry = d?.__typename === 'WorkspaceLocationEntry' ? d : null; - return locationEntry?.versionKey !== statusEntry?.versionKey; - }); - prevLocationStatuses.current = locationStatuses; - return toFetch; - }, [didLoadStatusData, isRefetching, locationStatuses, locationEntriesData]); + const loading = + loadingLocationStatuses || + !Object.keys(locationStatuses).every((locationName) => locationEntryData[locationName]); useLayoutEffect(() => { - if (!locationsToFetch.length) { + (async () => { + const params: LoadCachedLocationDataParams = { + localCacheIdPrefix, + getCachedData, + previousLocationVersionsRef, + }; + const cachedData = await loadCachedLocationData(params); + setLocationEntryData(cachedData); + })(); + }, [localCacheIdPrefix, getCachedData]); + + useEffect(() => { + const params: RefreshLocationsIfNeededParams = { + locationStatuses, + locationEntryData, + client, + localCacheIdPrefix, + getData, + setLocationEntryData, + previousLocationVersionsRef, + }; + refreshLocationsIfNeeded(params); + }, [locationStatuses, locationEntryData, client, localCacheIdPrefix, getData, loading]); + + useEffect(() => { + if (loading) { return; } - setIsRefetching(true); - Promise.all( - locationsToFetch.map(async (location) => { - return await refetchLocation(location.name); - }), - ).then(() => { - setIsRefetching(false); - }); - }, [refetchLocation, locationsToFetch]); - - const locationsRemoved = useMemo( - () => - Array.from( - new Set([ - ...Object.values(prevLocationStatuses.current).filter( - (loc) => loc && !locationStatuses[loc.name], - ), - ...Object.values(locationEntriesData).filter( - (loc): loc is WorkspaceLocationNodeFragment => - loc && loc?.__typename === 'WorkspaceLocationEntry' && !locationStatuses[loc.name], - ), - ]), - ), - [locationStatuses, locationEntriesData], - ); - useLayoutEffect(() => { - if (!locationsRemoved.length) { - return; - } - const copy = {...locationEntriesData}; - locationsRemoved.forEach((loc) => { - delete copy[loc.name]; - clearCachedData({key: `${localCacheIdPrefix}${locationWorkspaceKey(loc.name)}`}); - }); - if (Object.keys(copy).length !== Object.keys(locationEntriesData).length) { - setLocationEntriesData(copy); - } - }, [clearCachedData, localCacheIdPrefix, locationEntriesData, locationsRemoved]); + const params: HandleDeletedLocationsParams = { + locationStatuses, + clearCachedData, + localCacheIdPrefix, + setLocationEntryData, + previousLocationVersionsRef, + }; + handleDeletedLocations(params); + }, [locationStatuses, clearCachedData, localCacheIdPrefix, setLocationEntryData, loading]); const locationEntries = useMemo( - () => - Object.values(locationEntriesData).filter( - (entry): entry is WorkspaceLocationNodeFragment => - !!entry && entry.__typename === 'WorkspaceLocationEntry', - ), - [locationEntriesData], + () => extractLocationEntries(locationEntryData), + [locationEntryData], ); - - const allRepos = React.useMemo(() => { - let allRepos: DagsterRepoOption[] = []; - - allRepos = sortBy( - locationEntries.reduce((accum, locationEntry) => { - if (locationEntry.locationOrLoadError?.__typename !== 'RepositoryLocation') { - return accum; - } - const repositoryLocation = locationEntry.locationOrLoadError; - const reposForLocation = repoLocationToRepos(repositoryLocation); - accum.push(...reposForLocation); - return accum; - }, [] as DagsterRepoOption[]), - - // Sort by repo location, then by repo - (r) => `${r.repositoryLocation.name}:${r.repository.name}`, - ); - - return allRepos; - }, [locationEntries]); + const allRepos = useAllRepos(locationEntries); const {visibleRepos, toggleVisible, setVisible, setHidden} = useVisibleRepos(allRepos); - const locationsRef = useUpdatingRef(locationStatuses); - const refetch = useCallback(async () => { - return await Promise.all( - Object.values(locationsRef.current).map(async (location) => { - const result = await refetchLocation(location.name); - return result.data; - }), - ); - }, [locationsRef, refetchLocation]); + const promises = Object.values(locationStatuses).map(async (location) => { + const params: FetchLocationDataParams = { + name: location.name, + client, + localCacheIdPrefix, + getData, + setLocationEntryData, + }; + return await fetchLocationData(params); + }); + + const results = (await Promise.all(promises)).filter((x) => x) as Array; + return results; + }, [locationStatuses, client, localCacheIdPrefix, getData]); return ( locationEntriesData[locationName]) - ), + loading, locationEntries, locationStatuses, allRepos, @@ -308,8 +201,7 @@ export const WorkspaceProvider = ({children}: {children: React.ReactNode}) => { toggleVisible, setVisible, setHidden, - - data: locationEntriesData, + data: locationEntryData, refetch, }} > @@ -318,17 +210,301 @@ export const WorkspaceProvider = ({children}: {children: React.ReactNode}) => { ); }; -function getLocations(d: CodeLocationStatusQuery | undefined | null) { - const locations = - d?.locationStatusesOrError?.__typename === 'WorkspaceLocationStatusEntries' - ? d?.locationStatusesOrError.entries - : []; +/** + * Loads cached location data from IndexedDB. + */ +async function loadCachedLocationData( + params: LoadCachedLocationDataParams, +): Promise> { + const {localCacheIdPrefix, getCachedData, previousLocationVersionsRef} = params; + const cachedData: Record = {}; + const cachedStatusData = await getCachedData({ + key: `${localCacheIdPrefix}${CODE_LOCATION_STATUS_QUERY_KEY}`, + version: CodeLocationStatusQueryVersion, + }); + const cachedLocations = extractLocationStatuses(cachedStatusData); - return locations.reduce( - (accum, loc) => { - accum[loc.name] = loc; - return accum; + await Promise.all( + Object.values(cachedLocations).map(async (location) => { + const locationData = await getCachedData({ + key: `${localCacheIdPrefix}${locationWorkspaceKey(location.name)}`, + version: LocationWorkspaceQueryVersion, + }); + const entry = locationData?.workspaceLocationEntryOrError; + if (entry) { + cachedData[location.name] = entry; + } + }), + ); + + previousLocationVersionsRef.current = mapLocationVersions(cachedLocations); + return cachedData; +} + +/** + * Refreshes locations if they are stale based on versioning. + */ +async function refreshLocationsIfNeeded(params: RefreshLocationsIfNeededParams): Promise { + const { + locationStatuses, + locationEntryData, + client, + localCacheIdPrefix, + getData, + setLocationEntryData, + previousLocationVersionsRef, + } = params; + + const locationsToFetch = identifyStaleLocations( + locationStatuses, + locationEntryData, + previousLocationVersionsRef.current, + ); + + if (locationsToFetch.length === 0) { + return; + } + + await Promise.all( + locationsToFetch.map((location) => + fetchLocationData({ + name: location.name, + client, + localCacheIdPrefix, + getData, + setLocationEntryData, + }), + ), + ); + + previousLocationVersionsRef.current = mapLocationVersions(locationStatuses); +} + +/** + * Handles the cleanup of deleted locations by removing them from the cache and state. + */ +function handleDeletedLocations(params: HandleDeletedLocationsParams): void { + const { + locationStatuses, + clearCachedData, + localCacheIdPrefix, + setLocationEntryData, + previousLocationVersionsRef, + } = params; + + const removedLocations = identifyRemovedLocations( + previousLocationVersionsRef.current, + locationStatuses, + ); + + if (removedLocations.length === 0) { + return; + } + + removedLocations.forEach((name) => { + clearCachedData({ + key: `${localCacheIdPrefix}${locationWorkspaceKey(name)}`, + }); + }); + + setLocationEntryData((prevData) => { + const updatedData = {...prevData}; + removedLocations.forEach((name) => { + delete updatedData[name]; + }); + return updatedData; + }); + + previousLocationVersionsRef.current = mapLocationVersions(locationStatuses); +} + +/** + * Fetches data for a specific location. + */ +async function fetchLocationData( + params: FetchLocationDataParams, +): Promise { + const {name, client, localCacheIdPrefix, getData, setLocationEntryData} = params; + try { + const {data} = await getData({ + client, + query: LOCATION_WORKSPACE_QUERY, + key: `${localCacheIdPrefix}${locationWorkspaceKey(name)}`, + version: LocationWorkspaceQueryVersion, + variables: {name}, + bypassCache: true, + }); + + const entry = data?.workspaceLocationEntryOrError; + if (entry) { + setLocationEntryData((prevData) => ({ + ...prevData, + [name]: entry, + })); + } + return data; + } catch (error) { + console.error(`Error fetching location data for ${name}:`, error); + } + return undefined; +} + +/** + * Extracts location statuses from the provided query data. + * + * @param data - The result of the CodeLocationStatusQuery. + * @returns A record mapping location names to their status entries. + */ +function extractLocationStatuses( + data: CodeLocationStatusQuery | undefined | null, +): Record { + if (data?.locationStatusesOrError?.__typename !== 'WorkspaceLocationStatusEntries') { + return {}; + } + return data.locationStatusesOrError.entries.reduce( + (acc, loc) => { + acc[loc.name] = loc; + return acc; }, - {} as Record, + {} as Record, ); } + +/** + * Maps location statuses to their corresponding version keys. + * + * @param locationStatuses - A record of location statuses. + * @returns A record mapping location names to their version keys. + */ +function mapLocationVersions( + locationStatuses: Record, +): Record { + return Object.entries(locationStatuses).reduce( + (acc, [name, status]) => { + acc[name] = status.versionKey || ''; + return acc; + }, + {} as Record, + ); +} + +/** + * Identifies locations that are stale and need to be refreshed. + * + * @param locationStatuses - Current location statuses. + * @param locationEntryData - Current location entry data. + * @param previousLocationVersions - Previously recorded location versions. + * @returns An array of location status entries that are stale. + */ +function identifyStaleLocations( + locationStatuses: Record, + locationEntryData: Record, + previousLocationVersions: Record, +): LocationStatusEntryFragment[] { + return Object.values(locationStatuses).filter((statusEntry) => { + const prevVersion = previousLocationVersions[statusEntry.name]; + const currentVersion = statusEntry.versionKey || ''; + const entry = locationEntryData[statusEntry.name]; + const locationEntry = entry?.__typename === 'WorkspaceLocationEntry' ? entry : null; + const dataVersion = locationEntry?.versionKey || ''; + return currentVersion !== prevVersion || currentVersion !== dataVersion; + }); +} + +/** + * Identifies locations that have been removed from the current statuses. + * + * @param previousStatuses - Previously recorded location versions. + * @param currentStatuses - Current location statuses. + * @returns An array of location names that have been removed. + */ +function identifyRemovedLocations( + previousStatuses: Record, + currentStatuses: Record, +): string[] { + return Object.keys(previousStatuses).filter((name) => !currentStatuses[name]); +} + +/** + * Extracts location entries from the provided location entry data. + * + * @param locationEntryData - A record of location entry data. + * @returns An array of workspace location node fragments. + */ +function extractLocationEntries( + locationEntryData: Record, +): WorkspaceRepositoryLocationNode[] { + return Object.values(locationEntryData).filter( + (entry): entry is WorkspaceLocationNodeFragment => + entry?.__typename === 'WorkspaceLocationEntry', + ); +} + +/** + * Retrieves and sorts all repositories from the provided location entries. + * + * @param locationEntries - An array of workspace repository location nodes. + * @returns A sorted array of Dagster repository options. + */ +function useAllRepos(locationEntries: WorkspaceRepositoryLocationNode[]) { + return useMemo(() => { + const repos = locationEntries.reduce((accum, locationEntry) => { + if (locationEntry.locationOrLoadError?.__typename !== 'RepositoryLocation') { + return accum; + } + const repositoryLocation = locationEntry.locationOrLoadError; + accum.push(...repoLocationToRepos(repositoryLocation)); + return accum; + }, [] as DagsterRepoOption[]); + + return sortBy(repos, (r) => `${r.repositoryLocation.name}:${r.repository.name}`); + }, [locationEntries]); +} + +/** + * Custom hook to retrieve code location statuses using IndexedDB caching. + * + * @param localCacheIdPrefix - Optional prefix for cache keys. + * @returns An object containing location statuses and loading state. + */ +function useCodeLocationStatuses(localCacheIdPrefix: string | undefined) { + const {data: codeLocationStatusData, fetch} = useIndexedDBCachedQuery< + CodeLocationStatusQuery, + CodeLocationStatusQueryVariables + >({ + query: CODE_LOCATION_STATUS_QUERY, + version: CodeLocationStatusQueryVersion, + key: `${localCacheIdPrefix}${CODE_LOCATION_STATUS_QUERY_KEY}`, + }); + + if (typeof jest === 'undefined') { + // Only do this outside of jest for now so that we don't need to add RecoilRoot around everything... + // we will switch to jotai at some point instead... which doesnt require a + // eslint-disable-next-line react-hooks/rules-of-hooks + const setCodeLocationStatusAtom = useSetRecoilState(codeLocationStatusAtom); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useLayoutEffect(() => { + if (codeLocationStatusData) { + setCodeLocationStatusAtom(codeLocationStatusData); + } + }, [codeLocationStatusData, setCodeLocationStatusAtom]); + } + + const refresh = useCallback(async () => { + await fetch(); + }, [fetch]); + + useRefreshAtInterval({ + refresh, + intervalMs: 5000, + leading: true, + }); + + const locationStatuses = useMemo( + () => extractLocationStatuses(codeLocationStatusData), + [codeLocationStatusData], + ); + + return {locationStatuses, loading: !codeLocationStatusData}; +} diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/__tests__/WorkspaceContext.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/__tests__/WorkspaceContext.test.tsx index 9409e595bdecc..f5714df54a5a9 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/__tests__/WorkspaceContext.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext/__tests__/WorkspaceContext.test.tsx @@ -8,6 +8,7 @@ import {RecoilRoot} from 'recoil'; import {AppContext} from '../../../app/AppContext'; import { + buildPythonError, buildRepository, buildRepositoryLocation, buildWorkspaceLocationEntry, @@ -437,4 +438,76 @@ describe('WorkspaceContext', () => { [location2.name]: updatedLocation2, }); }); + + it('Handles code location load errors gracefully', async () => { + const errorMessage = 'Failed to load code location'; + const error = buildPythonError({message: errorMessage}); + const {location1, location2, location3, caches} = getLocationMocks(0); + + // Set location2 to have an error + location2.locationOrLoadError = error; + + const mocks = buildWorkspaceMocks([location1, location2, location3], {delay: 10}); + const mockCbs = mocks.map(getMockResultFn); + + caches.codeLocationStatusQuery.has.mockResolvedValue(false); + caches.location1.has.mockResolvedValue(false); + caches.location2.has.mockResolvedValue(false); + caches.location3.has.mockResolvedValue(false); + + const {result} = renderWithMocks(mocks); + + expect(result.current.loading).toEqual(true); + + // Run the code location status query + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + // Run the individual location queries + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + expect(mockCbs[1]).toHaveBeenCalled(); + expect(mockCbs[2]).toHaveBeenCalled(); + expect(mockCbs[3]).toHaveBeenCalled(); + + await waitFor(() => { + expect(result.current.loading).toEqual(false); + }); + + expect(result.current.data).toEqual({ + [location1.name]: location1, + [location2.name]: location2, + [location3.name]: location3, + }); + + // Verify that repositories from location2 are not included + expect(result.current.allRepos).toEqual([ + ...repoLocationToRepos(location1.locationOrLoadError as any), + ...repoLocationToRepos(location3.locationOrLoadError as any), + ]); + }); + + it('Handles empty code location status gracefully', async () => { + const caches = getLocationMocks(0).caches; + caches.codeLocationStatusQuery.has.mockResolvedValue(false); + + const mocks = buildWorkspaceMocks([], {delay: 10}); + + const {result} = renderWithMocks(mocks); + + // Initial load + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + await waitFor(() => { + expect(result.current.loading).toEqual(false); + }); + + expect(result.current.allRepos).toEqual([]); + expect(result.current.data).toEqual({}); + }); });