Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ const queryErrors = vi.hoisted(() => ({
errors: [] as { variant?: string; onRetry?: () => void }[],
}));

const connectivity = vi.hoisted(() => ({ value: 'online' as 'online' | 'offline' | 'unknown' }));

vi.mock('@/lib/hooks/use-offline-banner-state', () => ({
getCommittedConnectivityStatus: () => connectivity.value,
useCommittedConnectivityStatus: () => connectivity.value,
useOfflineBannerState: () => connectivity.value === 'offline',
}));

const buttons = vi.hoisted(() => ({
rendered: [] as {
children?: unknown;
Expand Down
13 changes: 6 additions & 7 deletions apps/mobile/src/components/organization/invoices-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import {
useOrgBoundary,
useOrgInvoicesPage,
} from '@/lib/hooks/use-organization-queries';
import { useCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import {
getInvoiceDownloadErrorMessage,
selectInvoiceRowState,
shareOrganizationInvoicePdf,
} from '@/lib/organization-invoice-download';
import { selectInvoiceErrorVariant } from '@/lib/organization-invoice-error-variant';
import { cn, firstNonEmpty } from '@/lib/utils';

// FlashList takes `contentContainerStyle` (no className), so the FlatList's
Expand Down Expand Up @@ -206,6 +208,7 @@ export function OrganizationInvoicesScreen() {
const { query, entries: invoices, hasMore } = useOrgInvoicesPage(organizationId);
const paddingBottom = useTabBarBottomPadding();
useRouteForegroundRefresh([[['organizations']]]);
const committedConnectivity = useCommittedConnectivityStatus();

// `InvoiceRow` is memoized at module scope, so the renderer keeps one identity
// across data updates and only the changed rows re-render.
Expand All @@ -229,13 +232,9 @@ export function OrganizationInvoicesScreen() {
errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED';

// NOT_FOUND maps to the not-found state; FORBIDDEN/UNAUTHORIZED map to the
// permission state. Any other error stays the retryable neutral state.
let errorVariant: 'neutral' | 'server' | 'not-found' | 'permission' = 'neutral';
if (errorCode === 'NOT_FOUND') {
errorVariant = 'not-found';
} else if (errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED') {
errorVariant = 'permission';
}
// permission state. A read that settled by failing on a confirmed-offline
// device is a connectivity failure; `unknown` keeps the retryable neutral.
const errorVariant = selectInvoiceErrorVariant(errorCode, committedConnectivity);

// A later-page failure must keep the already-loaded rows and offer an inline
// retry instead of replacing the list. Read `isFetchNextPageError`, not
Expand Down
34 changes: 33 additions & 1 deletion apps/mobile/src/components/profile-credits-card.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createElement, type ElementType, type ReactNode } from 'react';
import type * as ReactModule from 'react';
import { Platform, Pressable } from 'react-native';
import { act, type ReactTestRenderer } from '@/test/renderer';
import { type QueryClient } from '@tanstack/react-query';
import { onlineManager, type QueryClient } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import '@/i18n';
Expand Down Expand Up @@ -253,6 +253,8 @@ function headerClasses(renderer: ReactTestRenderer): string[] {
beforeEach(() => {
Platform.OS = 'ios';
windowDims.width = 390;
// A test that simulates the app reporting offline must not leak that state.
onlineManager.setOnline(true);
savedMetadata.clear();
saveCompletion = undefined;
addCreditsRowProps.latest = undefined;
Expand Down Expand Up @@ -523,6 +525,36 @@ describe('CreditsCard add-credits entry point', () => {
}
);

it('settles the balance read while the app reports offline', async () => {
// NetInfo can hold the device offline while the backend is reachable. A
// read that pauses there is pending without fetching, so the slot never
// leaves its skeleton: the card must ask Query to settle regardless.
currentUser.userId = 'user-1';
getContextBalanceQueryFn.mockResolvedValue({ balance: 12.5 });
onlineManager.setOnline(false);

const { texts, unmount } = await mountCard();
await waitFor(() => texts().includes('$12.50'));

expect(texts()).not.toContain('SKELETON');
unmount();
onlineManager.setOnline(true);
});

it('settles an unreachable balance read to the retry surface while offline', async () => {
currentUser.userId = 'user-1';
getContextBalanceQueryFn.mockRejectedValue(new Error('Network request failed'));
onlineManager.setOnline(false);

const { texts, unmount } = await mountCard();
await waitFor(() => texts().includes('Failed to load balance. Tap to retry.'));

expect(texts()).not.toContain('SKELETON');
expect(texts()).not.toContain('$0.00');
unmount();
onlineManager.setOnline(true);
});

it('keeps personal Buy credits actionable when the balance request fails', async () => {
currentUser.userId = 'user-1';
getContextBalanceQueryFn.mockRejectedValue(new Error('Balance unavailable'));
Expand Down
23 changes: 19 additions & 4 deletions apps/mobile/src/components/profile-credits-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,30 @@ export function CreditsCard({ enabled, orgs }: Readonly<CreditsCardProps>) {
...balanceOptions,
enabled: enabled && hasUserId,
placeholderData: keepPreviousData,
// The card must stop waiting while the online manager reports offline: a
// paused read is pending without fetching, so the skeleton below had no
// exit. This one either answers or fails once the control-plane deadline
// lands, and `balanceFailed` then offers the retry.
networkMode: 'always',
// `networkMode: 'always'` also disables the derived reconnect refetch
// (`@tanstack/query-core` sets `refetchOnReconnect = networkMode !==
// 'always'` when it is undefined), so ask for it explicitly.
refetchOnReconnect: true,
});

const { data: personalCreditData, isLoading: personalCreditsLoading } = useQuery({
...personalCreditOptions,
enabled: enabled && hasUserId && !selectedOrgId,
networkMode: 'always',
refetchOnReconnect: true,
});

const { data: orgCreditData, isLoading: orgCreditsLoading } = useQuery({
...orgCreditOptions,
enabled: enabled && hasUserId && Boolean(selectedOrgId),
placeholderData: keepPreviousData,
networkMode: 'always',
refetchOnReconnect: true,
});

// A failed getMe (no userId) can never render a trusted balance, so it shares
Expand All @@ -103,10 +116,12 @@ export function CreditsCard({ enabled, orgs }: Readonly<CreditsCardProps>) {
const creditsLoading = selectedOrgId ? orgCreditsLoading : personalCreditsLoading;

const balanceDollars = balance?.balance ?? 0;
// A paused query (offline/unknown connectivity, empty cache) is pending but
// not fetching, so `balanceLoading` (isLoading) is false while `balance`
// is still undefined. Treat "no data yet" as loading so the card shows a
// skeleton instead of `$0` on a cold launch before NetInfo settles.
// A read that has not answered yet is pending without fetching while the
// owner is still unresolved (the balance query is gated on the identity), so
// `balanceLoading` (isLoading) is false while `balance` is still undefined.
// Treat "no data yet" as loading so the card shows a skeleton instead of `$0`
// on a cold launch. The read itself settles regardless of the online manager,
// and an unreachable one lands on `balanceFailed` instead of this branch.
const balancePending = balance === undefined && !balanceFailed;
const showBalanceSkeleton =
(balanceLoading || balancePending || !hideBalanceLoaded) && !balanceFailed;
Expand Down
59 changes: 59 additions & 0 deletions apps/mobile/src/components/profile-screen.queries.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { onlineManager } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// The mock harness is imported before any product module so its `vi.mock`
// calls register before the modules under test are loaded.
import {
advanceUntil,
authState,
connectivityState,
findConfigureRows,
findNode,
flushInteractions,
Expand All @@ -27,6 +29,9 @@ import { createTestQueryClient, waitFor } from '@/test/render-with-providers';
describe('ProfileScreen deferred queries', () => {
beforeEach(() => {
vi.useRealTimers();
// A test that simulates the app reporting offline must not leak that state.
onlineManager.setOnline(true);
connectivityState.value = 'online';
providersQueryFn.mockReset();
organizationsQueryFn.mockReset();
signOutFn.mockReset();
Expand Down Expand Up @@ -206,6 +211,60 @@ describe('ProfileScreen deferred queries', () => {
unmount();
});

it('settles the linked-accounts read while the app reports offline', async () => {
// NetInfo can hold the device offline while the backend is reachable. A
// paused read is pending without fetching, so neither the row nor the error
// surface renders: the screen must ask Query to settle regardless.
providersQueryFn.mockResolvedValue({
providers: [{ provider: 'github', email: 'dev@kilo.ai' }],
});
organizationsQueryFn.mockResolvedValue([]);
onlineManager.setOnline(false);

const { renderer, unmount } = await mountProfile();

flushInteractions();
await waitFor(() => findConfigureRows(renderer.root, 'GitHub').length === 1);

expect(findConfigureRows(renderer.root, 'GitHub')[0]?.props.subtitle).toBe('dev@kilo.ai');
expect(nodeCount(renderer.root, 'Skeleton')).toBe(0);

unmount();
});

it('settles an unreachable linked-accounts read to its retry surface while offline', async () => {
providersQueryFn.mockRejectedValue(new Error('Network request failed'));
organizationsQueryFn.mockResolvedValue([]);
onlineManager.setOnline(false);

const { renderer, unmount } = await mountProfile();

flushInteractions();
await waitFor(() => nodeCount(renderer.root, 'QueryError') > 0);

const queryError = findNode(renderer.root, 'QueryError');
expect(queryError?.props.title).toBe('Could not load accounts');
expect(typeof queryError?.props.onRetry).toBe('function');
expect(nodeCount(renderer.root, 'Skeleton')).toBe(0);

unmount();
});

it('names connectivity when the failed read lands on a confirmed-offline device', async () => {
providersQueryFn.mockRejectedValue(new Error('Network request failed'));
organizationsQueryFn.mockResolvedValue([]);
connectivityState.value = 'offline';

const { renderer, unmount } = await mountProfile();

flushInteractions();
await waitFor(() => nodeCount(renderer.root, 'QueryError') > 0);

expect(findNode(renderer.root, 'QueryError')?.props.variant).toBe('offline');

unmount();
});

it('does not fire the queries when unauthenticated, even after the flush', async () => {
authState.token = null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ vi.mock('@/lib/hooks/use-current-user-id', () => ({
useCurrentUserId: () => ({ userId: 'user-1' }),
}));

vi.mock('@/lib/hooks/use-offline-banner-state', () => ({
getCommittedConnectivityStatus: () => 'online',
useCommittedConnectivityStatus: () => 'online',
useOfflineBannerState: () => false,
}));

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ destructive: '#B0483A', mutedForeground: '#000000' }),
}));
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/components/profile-screen.test-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ const captureInteraction = vi.hoisted(() => (cb: () => void) => {
});
const getProfileAgentScopeMock = vi.hoisted(() => vi.fn());
const openExternalUrlFn = vi.hoisted(() => vi.fn());
const connectivityState = vi.hoisted(() => ({
value: 'online' as 'online' | 'offline' | 'unknown',
}));

export {
authState,
connectivityState,
getProfileAgentScopeMock,
interactionState,
keys,
Expand Down Expand Up @@ -104,6 +108,12 @@ vi.mock('@/lib/hooks/use-current-user-id', () => ({
useCurrentUserId: () => ({ userId: 'user-1' }),
}));

vi.mock('@/lib/hooks/use-offline-banner-state', () => ({
getCommittedConnectivityStatus: () => connectivityState.value,
useCommittedConnectivityStatus: () => connectivityState.value,
useOfflineBannerState: () => connectivityState.value === 'offline',
}));

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ mutedForeground: '#000000' }),
}));
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/components/profile-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { useAuth } from '@/lib/auth/auth-context';
import { openExternalUrl } from '@/lib/external-link';
import { useAfterInteractions } from '@/lib/hooks/use-after-interactions';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state';
import { useOrganization } from '@/lib/organization-context';
import {
getCodeReviewerProfilePath,
Expand Down Expand Up @@ -103,6 +104,16 @@ export function ProfileScreen() {
} = useQuery({
...trpc.user.getAuthProviders.queryOptions(),
enabled: isAuthenticated && afterInteractions,
// The Linked accounts row must settle while the online manager reports
// offline: a paused read is pending without fetching, so `isLoading` is
// false and the section neither paints a row nor an error. This one either
// answers or fails once the control-plane deadline lands, and the error
// surface below then offers Retry.
networkMode: 'always',
// `networkMode: 'always'` also disables the derived reconnect refetch
// (`@tanstack/query-core` sets `refetchOnReconnect = networkMode !==
// 'always'` when it is undefined), so ask for it explicitly.
refetchOnReconnect: true,
});
const {
data: orgs,
Expand All @@ -126,6 +137,10 @@ export function ProfileScreen() {
const feedbackPrompt = useFeedbackPrompt();

const { t } = useTranslation();
// Reactive: a connectivity commit re-renders the error-variant selection, so
// a read that settled by failing while the device is confirmed offline says
// so instead of presenting itself as a server fault.
const committedConnectivity = useCommittedConnectivityStatus();

const requestSignOut = () => {
confirm({
Expand Down Expand Up @@ -283,7 +298,7 @@ export function ProfileScreen() {
</Text>
{organizationsError ? (
<QueryError
variant="server"
variant={committedConnectivity === 'offline' ? 'offline' : 'server'}
placement="top"
title={t('profile.couldNotLoadOrganization')}
message={t('profile.couldNotLoadOrganizationDescription')}
Expand Down Expand Up @@ -404,7 +419,7 @@ export function ProfileScreen() {

{providersError && (
<QueryError
variant="server"
variant={committedConnectivity === 'offline' ? 'offline' : 'server'}
placement="top"
title={t('profile.couldNotLoadAccounts')}
onRetry={() => void refetchProviders()}
Expand Down
21 changes: 18 additions & 3 deletions apps/mobile/src/lib/hooks/use-current-user-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,27 @@ const query = vi.hoisted(() => ({
refetch: vi.fn(),
}));

vi.mock('@tanstack/react-query', () => ({ useQuery: () => query }));
const manager = vi.hoisted(() => ({ online: true }));

vi.mock('@tanstack/react-query', () => ({
useQuery: () => query,
onlineManager: {
isOnline: () => manager.online,
subscribe: () => () => undefined,
},
}));
// The hook subscribes to the manager through `useSyncExternalStore`; this suite
// calls hooks directly, so the mock reads the current snapshot.
vi.mock('react', () => ({
useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown): unknown => getSnapshot(),
}));
vi.mock('@/lib/trpc', () => ({
useTRPC: () => ({ user: { getMe: { queryOptions: () => ({}) } } }),
}));

describe('useCurrentUserId', () => {
beforeEach(() => {
manager.online = true;
Object.assign(query, {
data: undefined,
error: null,
Expand Down Expand Up @@ -91,8 +105,9 @@ describe('useCurrentUserId', () => {
});

describe('isUnreachable', () => {
it('holds while the device is offline and the request waits for a connection', () => {
Object.assign(query, { fetchStatus: 'paused' });
it('holds at once when the manager reports offline, before the attempt settles', () => {
manager.online = false;
Object.assign(query, { isLoading: true, fetchStatus: 'fetching' });

expect(useCurrentUserId().isUnreachable).toBe(true);
});
Expand Down
Loading
Loading