diff --git a/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx index c90fe2e2ec..ade821eeb6 100644 --- a/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-history-screen.mounted.test.tsx @@ -24,6 +24,12 @@ const listState = vi.hoisted(() => ({ isSearching: false, isError: false, organization: { organizationId: null as string | null, isLoaded: true }, + // Mirrors the real hook's stored-query flags so the screen's loading + // decision can be exercised on the first render, before the request + // settles (isFetching false, isPending true). + storedIsPending: false, + storedIsFetching: false, + storedLoadedPageCount: 1, storedQuery: vi.fn<(options: Parameters[0]) => void>(), searchQuery: vi.fn<(options: Parameters[0]) => void>(), repositoryQuery: vi.fn<(options: Parameters[0]) => void>(), @@ -135,8 +141,9 @@ vi.mock('@/lib/hooks/use-agent-sessions', async () => { dateGroups: storedSessions.length > 0 ? [{ label: 'Today', sessions: storedSessions }] : [], activeIsError: false, storedIsError: listState.isError, - storedIsFetching: false, - storedLoadedPageCount: 1, + storedIsPending: listState.storedIsPending, + storedIsFetching: listState.storedIsFetching, + storedLoadedPageCount: listState.storedLoadedPageCount, hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), @@ -258,6 +265,9 @@ describe('SessionHistoryScreen', () => { listState.storedSessions = []; listState.isSearching = false; listState.isError = false; + listState.storedIsPending = false; + listState.storedIsFetching = false; + listState.storedLoadedPageCount = 1; Object.assign(listState.organization, { organizationId: null, isLoaded: true }); listState.storedQuery.mockClear(); listState.searchQuery.mockClear(); @@ -460,6 +470,50 @@ describe('SessionHistoryScreen', () => { } ); + // Regression: the empty state ("No past sessions") must not flash on the + // cold-open render. React Query v5 reports `isFetching: false` until the + // observer subscribes and starts the first fetch, while `isPending` stays + // true until the query settles — the screen must treat that first frame as + // loading, not as a settled empty list. + it('shows loading on the cold-open render before the request settles', async () => { + listState.storedSessions = []; + listState.storedIsPending = true; + listState.storedIsFetching = false; + listState.storedLoadedPageCount = 0; + + const renderer = await renderScreen(); + + const content = findNodeByType(renderer, 'AgentSessionListContent'); + expect(content.props.isLoading).toBe(true); + expect(content.props.hasAnySessions).toBe(false); + }); + + it('stops loading and renders cached rows during a background refetch', async () => { + listState.storedSessions = [{ session_id: 'cached', organization_id: null }]; + listState.storedIsPending = false; + listState.storedIsFetching = true; + listState.storedLoadedPageCount = 1; + + const renderer = await renderScreen(); + + const content = findNodeByType(renderer, 'AgentSessionListContent'); + expect(content.props.isLoading).toBe(false); + expect(findNodeByType(renderer, 'AgentSessionListContent').props.sections).toHaveLength(1); + }); + + it('shows the settled empty state once the request completes with no rows', async () => { + listState.storedSessions = []; + listState.storedIsPending = false; + listState.storedIsFetching = false; + listState.storedLoadedPageCount = 1; + + const renderer = await renderScreen(); + + const content = findNodeByType(renderer, 'AgentSessionListContent'); + expect(content.props.isLoading).toBe(false); + expect(content.props.hasAnySessions).toBe(false); + }); + it('renders the agents title with a back button and default header size', async () => { const renderer = await renderScreen(); const header = findNodeByType(renderer, 'ScreenHeader'); diff --git a/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx b/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx index 5234712573..ef4afb779a 100644 --- a/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-history-screen.pull-failure.mounted.test.tsx @@ -15,6 +15,7 @@ type MockStoredSession = Pick & const listState = vi.hoisted(() => ({ storedSessions: [] as MockStoredSession[], isError: false, + storedIsPending: false, storedIsFetching: false, })); @@ -188,6 +189,7 @@ vi.mock('@/lib/hooks/use-agent-sessions', () => ({ dateGroups: storedSessions.length > 0 ? [{ label: 'Today', sessions: storedSessions }] : [], activeIsError: false, storedIsError: listState.isError, + storedIsPending: listState.storedIsPending, storedIsFetching: listState.storedIsFetching, storedLoadedPageCount: 1, hasNextPage: false, @@ -261,6 +263,7 @@ beforeEach(() => { }, ]; listState.isError = false; + listState.storedIsPending = false; listState.storedIsFetching = false; }); diff --git a/apps/mobile/src/components/agents/session-history-screen.tsx b/apps/mobile/src/components/agents/session-history-screen.tsx index fab7712831..bcc82cc5e3 100644 --- a/apps/mobile/src/components/agents/session-history-screen.tsx +++ b/apps/mobile/src/components/agents/session-history-screen.tsx @@ -6,6 +6,7 @@ import { useFocusEffect, useNavigation } from 'expo-router'; import { SessionFilterModal } from '@/components/agents/platform-filter-modal'; import { AgentSessionListContent } from '@/components/agents/session-list-content'; import { SessionListHeaderActions } from '@/components/agents/session-list-header-actions'; +import { selectSessionListIsLoading } from '@/components/agents/session-list-loading'; import { selectShowSearchBusy } from '@/components/agents/session-list-search-busy'; import { SessionListSearchHeader } from '@/components/agents/session-list-search-header'; import { useAgentSessionListData } from '@/components/agents/use-agent-session-list-data'; @@ -76,8 +77,7 @@ export function SessionHistoryScreen() { const { storedSessions, activeSessionIds, - storedIsFetching, - storedLoadedPageCount, + storedIsPending, paging, handleRetry, handleRefetch, @@ -163,8 +163,12 @@ export function SessionHistoryScreen() { clearFilters(); }, [clearSearchInput, searchController, clearFilters, isSearching]); - const isLoading = - !ready || (isSearching ? search.isPending : storedIsFetching && storedLoadedPageCount === 0); + const isLoading = selectSessionListIsLoading({ + ready, + isSearching, + searchIsPending: search.isPending, + storedIsPending, + }); return ( diff --git a/apps/mobile/src/components/agents/session-list-loading.test.ts b/apps/mobile/src/components/agents/session-list-loading.test.ts new file mode 100644 index 0000000000..3722150637 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-loading.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { selectSessionListContentSurface } from './session-list-content-surface'; +import { selectSessionListIsLoading } from './session-list-loading'; + +function loading(overrides: Partial[0]> = {}) { + return selectSessionListIsLoading({ + ready: true, + isSearching: false, + searchIsPending: false, + storedIsPending: false, + ...overrides, + }); +} + +describe('selectSessionListIsLoading', () => { + describe('cold open — first render before the request settles', () => { + it('treats the very first render as loading, not a settled empty list', () => { + // React Query v5 reports isLoading/isFetching false until the observer + // starts the fetch; only isPending is true. The surface must show + // skeletons, never the empty state, on this frame. + expect(loading({ isSearching: false, searchIsPending: false, storedIsPending: true })).toBe( + true + ); + }); + + it('stays loading until the query inputs resolve', () => { + expect(loading({ ready: false, isSearching: false, storedIsPending: true })).toBe(true); + expect(loading({ ready: false, isSearching: false, storedIsPending: false })).toBe(true); + }); + + it('treats a pending search on the first render as loading', () => { + expect(loading({ isSearching: true, searchIsPending: true, storedIsPending: false })).toBe( + true + ); + }); + }); + + describe('after load', () => { + it('is not loading once the stored query settles with no rows (true empty)', () => { + expect(loading({ isSearching: false, searchIsPending: false, storedIsPending: false })).toBe( + false + ); + }); + + it('stops loading when a search settles with no matches', () => { + expect(loading({ isSearching: true, searchIsPending: false, storedIsPending: false })).toBe( + false + ); + }); + + it('reads the search flag, not the stored flag, while searching', () => { + expect(loading({ isSearching: true, searchIsPending: false, storedIsPending: true })).toBe( + false + ); + expect(loading({ isSearching: false, searchIsPending: true, storedIsPending: false })).toBe( + false + ); + }); + }); + + // Ties the loading decision to the body-surface decision: the combination is + // what the screen actually renders, and it is where the flash came from. + describe('cold-open body-surface decision', () => { + const surfaceInput = { + isError: false, + hasAnySessions: false, + hasHistoryContent: false, + }; + + it('selects skeletons, never the history-empty surface, before the request settles', () => { + const isLoading = loading({ isSearching: false, storedIsPending: true }); + expect(selectSessionListContentSurface({ isLoading, ...surfaceInput })).toEqual({ + kind: 'section-list', + listEmpty: 'loading-skeletons', + }); + }); + + it('selects the history-empty surface only after the request settles', () => { + const isLoading = loading({ isSearching: false, storedIsPending: false }); + expect(selectSessionListContentSurface({ isLoading, ...surfaceInput })).toEqual({ + kind: 'history-empty', + }); + }); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-loading.ts b/apps/mobile/src/components/agents/session-list-loading.ts new file mode 100644 index 0000000000..db6ccdf6b0 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-loading.ts @@ -0,0 +1,29 @@ +/** + * Loading decision shared by the stored session-list surfaces (the history + * screen and any other surface that renders the same stored rows). + * + * React Query v5's `isLoading` is `isPending && isFetching`, so it is false on + * the first render — the observer has not started the fetch yet — and while a + * query is paused (offline). The body surfaces gate their empty/error states on + * this flag, so keying "no data yet" off `isFetching` lets a cold open paint + * "No past sessions" for a frame before the request settles. + * + * `isPending` stays true until the query settles (success or error), which is + * exactly the "keep showing skeletons until the request settles" contract. It + * is false as soon as any page is cached, so a background refetch never blanks + * out rows that are already rendered. + */ +export function selectSessionListIsLoading(input: { + /** Query inputs (org, persisted filters, identity) have resolved. */ + ready: boolean; + isSearching: boolean; + /** `search.isPending` — no search result cached yet. */ + searchIsPending: boolean; + /** `stored.isPending` — no stored page cached yet. */ + storedIsPending: boolean; +}): boolean { + if (!input.ready) { + return true; + } + return input.isSearching ? input.searchIsPending : input.storedIsPending; +} diff --git a/apps/mobile/src/components/agents/use-agent-session-list-data.ts b/apps/mobile/src/components/agents/use-agent-session-list-data.ts index 387cce7adf..f9118848b4 100644 --- a/apps/mobile/src/components/agents/use-agent-session-list-data.ts +++ b/apps/mobile/src/components/agents/use-agent-session-list-data.ts @@ -36,8 +36,7 @@ export function useAgentSessionListData(options: { dateGroups, activeIsError, storedIsError, - storedIsFetching, - storedLoadedPageCount, + storedIsPending, hasNextPage, isFetchingNextPage, fetchNextPage, @@ -149,8 +148,7 @@ export function useAgentSessionListData(options: { return { storedSessions, activeSessionIds, - storedIsFetching, - storedLoadedPageCount, + storedIsPending, paging, refetch, handleRetry, diff --git a/apps/mobile/src/components/device-sessions-screen.mounted.test.tsx b/apps/mobile/src/components/device-sessions-screen.mounted.test.tsx index 73b88605a8..4afad43319 100644 --- a/apps/mobile/src/components/device-sessions-screen.mounted.test.tsx +++ b/apps/mobile/src/components/device-sessions-screen.mounted.test.tsx @@ -10,6 +10,7 @@ import { TrustedHostsScreen } from './trusted-hosts-screen'; const query = vi.hoisted(() => ({ data: undefined as DeviceSession[] | undefined, isLoading: false, + isPending: false, isError: false, isFetching: false, refetch: vi.fn(), @@ -63,6 +64,7 @@ vi.mock('@/lib/format', () => ({ formatDate: () => 'Date' })); beforeEach(() => { query.data = undefined; query.isLoading = false; + query.isPending = false; query.isError = false; query.refetch.mockClear(); hosts.hasLoaded = true; @@ -110,7 +112,7 @@ describe('account surface states', () => { }); it('keeps device loading ahead of error and empty states', async () => { - query.isLoading = true; + query.isPending = true; query.isError = true; const { renderer, unmount } = await renderWithProviders(createElement(DeviceSessionsScreen)); expect(renderer.root.findAll(node => String(node.type) === 'Skeleton')).toHaveLength(12); @@ -119,6 +121,20 @@ describe('account surface states', () => { unmount(); }); + // Regression: React Query v5's `isLoading` is false on the first render + // before the observer starts fetching. The very first frame of a cold open + // must still be the skeleton, not the empty state. + it('shows the skeleton on the cold-open render before the request settles', async () => { + query.isPending = true; + query.isLoading = false; + query.isError = false; + query.data = undefined; + const { renderer, unmount } = await renderWithProviders(createElement(DeviceSessionsScreen)); + expect(renderer.root.findAll(node => String(node.type) === 'Skeleton')).toHaveLength(12); + expect(renderer.root.findAll(node => String(node.type) === 'EmptyState')).toHaveLength(0); + unmount(); + }); + it('lifts trusted host emptiness outside the scroller', async () => { const { renderer, unmount } = await renderWithProviders(createElement(TrustedHostsScreen)); expect(renderer.root.findAll(node => String(node.type) === 'TabScreenScrollView')).toHaveLength( diff --git a/apps/mobile/src/components/device-sessions-screen.tsx b/apps/mobile/src/components/device-sessions-screen.tsx index 8d2fe1c9a9..5e8cc47415 100644 --- a/apps/mobile/src/components/device-sessions-screen.tsx +++ b/apps/mobile/src/components/device-sessions-screen.tsx @@ -84,13 +84,16 @@ export function DeviceSessionsScreen() { const trpc = useTRPC(); const { t } = useTranslation(); - const { data, isLoading, isError, isFetching, refetch } = useQuery({ + const { data, isPending, isError, isFetching, refetch } = useQuery({ ...trpc.user.listDeviceSessions.queryOptions(), enabled: token != null, }); const state = classifyDeviceSessionsState({ - isLoading, + // `isPending`, not `isLoading`: React Query v5's `isLoading` is + // `isPending && isFetching`, so it is false on the first render before the + // fetch starts and the cold open would classify as `empty`. + isPending, isError: isError && data === undefined, data, }); diff --git a/apps/mobile/src/lib/device-sessions.test.ts b/apps/mobile/src/lib/device-sessions.test.ts index 0eb4c6c674..5e229961fd 100644 --- a/apps/mobile/src/lib/device-sessions.test.ts +++ b/apps/mobile/src/lib/device-sessions.test.ts @@ -73,23 +73,30 @@ describe('classifyDeviceSessionsState', () => { it.each([ { name: 'loading ahead of stale data', - args: { isLoading: true, isError: false, data: rows }, + args: { isPending: true, isError: false, data: rows }, + expected: 'loading', + }, + { + // Cold open, first render: the observer has not started fetching yet so + // React Query's `isLoading` would be false, but `isPending` is true. + name: 'the first render before the request settles as loading, not empty', + args: { isPending: true, isError: false, data: undefined }, expected: 'loading', }, { name: 'a query error as retryable error', - args: { isLoading: false, isError: true, data: undefined }, + args: { isPending: false, isError: true, data: undefined }, expected: 'error', }, { name: 'zero rows as empty', - args: { isLoading: false, isError: false, data: [] }, + args: { isPending: false, isError: false, data: [] }, expected: 'empty', }, { name: 'rows with a current row as happy', args: { - isLoading: false, + isPending: false, isError: false, data: [makeSession({ id: 'b', isCurrent: true }), ...rows], }, @@ -97,7 +104,7 @@ describe('classifyDeviceSessionsState', () => { }, { name: 'rows without a current row as no-current, never empty', - args: { isLoading: false, isError: false, data: rows }, + args: { isPending: false, isError: false, data: rows }, expected: 'no-current', }, ])('classifies $name', ({ args, expected }) => { diff --git a/apps/mobile/src/lib/device-sessions.ts b/apps/mobile/src/lib/device-sessions.ts index df0207b732..2b73c784d9 100644 --- a/apps/mobile/src/lib/device-sessions.ts +++ b/apps/mobile/src/lib/device-sessions.ts @@ -40,7 +40,14 @@ export function sortDeviceSessions(sessions: readonly DeviceSession[]): DeviceSe type DeviceSessionsQueryState = 'loading' | 'error' | 'empty' | 'happy' | 'no-current'; type ClassifyArgs = { - isLoading: boolean; + /** + * `isPending` (no data yet), NOT React Query v5's `isLoading` + * (`isPending && isFetching`): the first render before the observer starts + * the fetch and a paused (offline) query are pending but not fetching, so + * `isLoading` would classify a cold open as `empty` and flash the empty + * state before the request settles. + */ + isPending: boolean; isError: boolean; data: DeviceSession[] | undefined; }; @@ -53,11 +60,11 @@ type ClassifyArgs = { * note — never the empty state. */ export function classifyDeviceSessionsState({ - isLoading, + isPending, isError, data, }: ClassifyArgs): DeviceSessionsQueryState { - if (isLoading) { + if (isPending) { return 'loading'; } if (isError) { diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index 220377e3fd..fb9642863d 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts @@ -347,6 +347,12 @@ export function useAgentSessions(options?: UseAgentSessionsOptions) { // vs "keep showing stale data") should use these instead of `isError`. storedIsError: stored.isError, storedIsSuccess: stored.isSuccess, + // React Query v5's `isLoading` is `isPending && isFetching`, so it is false + // on the first render (the observer has not started the fetch yet) and + // while a query is paused (offline). `isPending` stays true until the query + // settles, so the list surfaces must key "no data yet" off this flag — a + // cached list has `isPending: false` and keeps rendering during a refetch. + storedIsPending: stored.isPending, // Any stored-list fetch in flight (initial load, refetch, next page), // used by the backfill selector to serialize automatic fetches behind // user- or focus-driven refetches on the same infinite query. The selector