Skip to content
Merged
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
9 changes: 7 additions & 2 deletions src/components/DynamicForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import ActivityIndicator from '@components/ActivityIndicator';

import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useSubPage from '@hooks/useSubPage';
import useThemeStyles from '@hooks/useThemeStyles';

import Navigation from '@libs/Navigation/Navigation';
import {findLastPageIndex} from '@libs/SubPageUtils';
Expand All @@ -16,6 +17,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

import {Str} from 'expensify-common';
import React, {useEffect, useRef, useState} from 'react';
import {View} from 'react-native';

import type {DynamicFormFlowProps, DynamicFormSubPageProps, DynamicFormValues} from './types';

Expand Down Expand Up @@ -60,6 +62,7 @@ function DynamicFormFlow({
layout = 'auto',
hasConfirmation = true,
}: DynamicFormFlowProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [draft, draftMetadata] = useOnyx(`${formID}Draft`);
const [sensitiveAnswersByForm] = useOnyx(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS);
Expand Down Expand Up @@ -312,7 +315,9 @@ function DynamicFormFlow({
shouldShowStepIndicator={isStepIndicatorAsked && stepNames.length > 1}
>
{isDraftLoading || isRedirecting || isOnUnavailablePage || (!hasPageToShow && pages.length > 0) ? (
<FullscreenLoadingIndicator />
<View style={[styles.flex1, styles.fullScreenLoading]}>
<ActivityIndicator size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE} />
</View>
) : (
<CurrentPage
isEditing={isEditing}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ReportOrNotFoundGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function ReportOrNotFoundGuard({reportID, shouldRequireReportID = true, children
}

if (decision === 'loading') {
return <FullscreenLoadingIndicator shouldUseGoBackButton />;
return <FullscreenLoadingIndicator />;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ UI-1 (docs)

This FullscreenLoadingIndicator is returned as the sole content for the 'loading' decision, with no navigation (header/back button) rendered in this branch. Removing shouldUseGoBackButton means that if loading hangs, the user has no escape route and is trapped on the screen. Since this prop is being migrated to become the default, it must be set explicitly for now.

Restore the prop:

if (decision === 'loading') {
    return <FullscreenLoadingIndicator shouldUseGoBackButton />;
}

Reviewed at: 041f4ff | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

@situchan situchan Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc is no longer valid.
shouldUseGoBackButton is now set to true default when omitted.
@MelvinBot create followup PR to update this doc so that claude no longer reports as bug. Instead, they should catch the bug of {shouldUseGoBackButton=false} being omitted when it doesn't make sense to go back.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 I couldn't open the follow-up PR. My environment blocks edits to files under .claude/, so I can't change .claude/skills/app-coding-standards/rules/ui-1-correct-loading-indicator.md. An engineer needs to apply the change. The full replacement file is below.

The new rule makes 3 changes:

  1. It stops flagging a missing shouldUseGoBackButton, because the prop defaults to true (src/components/FullscreenLoadingIndicator.tsx:39). It also stops flagging removal of an explicit shouldUseGoBackButton, like the 3 comments on this PR did.
  2. It flags a FullscreenLoadingIndicator without shouldUseGoBackButton={false} when a header with a back button renders in the same tree. Otherwise the loader stacks a second "Go Back" button over the header's back button.
  3. It flags the same omission where going back makes no sense, such as global overlays and sign-in or sign-out pages. Main already does this in FullScreenLoaderContext, LogOutPreviousUserPage, and ValidateLoginPage. The old "correct" example that used ValidateLoginPage with shouldUseGoBackButton was out of date.
Proposed ui-1-correct-loading-indicator.md
---
ruleId: UI-1
title: Use the correct loading indicator based on navigation context
---

## [UI-1] Use the correct loading indicator based on navigation context

### Reasoning

If loading hangs, users need an escape route. `FullscreenLoadingIndicator` provides one by default: `shouldUseGoBackButton` defaults to `true`, so it shows an emergency "Go Back" button after a timeout. Omitting the prop is correct when no navigation is visible.

The default is wrong when going back doesn't make sense:
- **Navigation is already visible.** If a header with a back button (or a close button) renders alongside the loader, the default "Go Back" button duplicates it. Prefer `ActivityIndicator` below the header, since `FullscreenLoadingIndicator` is absolutely positioned and draws over the header. If `FullscreenLoadingIndicator` stays, pass `shouldUseGoBackButton={false}`.
- **Going back is dead or harmful.** Global overlays, sign-in/sign-out transitions, and auth or session handoff pages have nowhere safe to go back to. Pass `shouldUseGoBackButton={false}`.

### Incorrect

```tsx
// Header and FullscreenLoadingIndicator in SAME return without shouldUseGoBackButton={false} - stacks a second "Go Back" button over the header's back button
<ScreenWrapper>
    <HeaderWithBackButton title="Settings" />
    <FullscreenLoadingIndicator />
</ScreenWrapper>

// Sign-in transition page - going back mid-transition is dead or harmful, so the default "Go Back" button must be turned off
function LogInWithShortLivedAuthTokenPage() {
    return <FullscreenLoadingIndicator />;
}

// ActivityIndicator as sole content without navigation - use FullscreenLoadingIndicator
function AuthLoadingPage() {
    return (
        <View style={[styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter]}>
            <ActivityIndicator size="large" />
        </View>
    );
}
```

### Correct

```tsx
// No navigation in return - FullscreenLoadingIndicator shows the "Go Back" button by default
function ReportLoadingPage() {
    return <FullscreenLoadingIndicator />;
}

// Loader and navigation in DIFFERENT returns - OK to use FullscreenLoadingIndicator with the default "Go Back" button
function SettingsPage() {
    if (isLoading) {
        return <FullscreenLoadingIndicator />;
    }
    return (
        <ScreenWrapper>
            <HeaderWithBackButton title="Settings" />
            <Content />
        </ScreenWrapper>
    );
}

// Header visible during loading - use ActivityIndicator
function SettingsPage() {
    return (
        <ScreenWrapper>
            <HeaderWithBackButton title="Settings" />
            {isLoading ? (
                <View style={[styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter]}>
                    <ActivityIndicator size="large" />
                </View>
            ) : (
                <Content />
            )}
        </ScreenWrapper>
    );
}

// Going back is dead or harmful - turn the "Go Back" button off
function LogInWithShortLivedAuthTokenPage() {
    return <FullscreenLoadingIndicator shouldUseGoBackButton={false} />;
}
```

---

### Review Metadata

Flag ONLY when ANY of these patterns is found:

- `FullscreenLoadingIndicator` without `shouldUseGoBackButton={false}` when `HeaderWithBackButton` (or other navigation like a close button) is rendered **in the same JSX tree** (not separated by conditionals into different returns). Suggest `ActivityIndicator` below the header, or at minimum `shouldUseGoBackButton={false}`
- `FullscreenLoadingIndicator` without `shouldUseGoBackButton={false}` where **going back is dead or harmful**: global overlays, sign-in/sign-out transitions, auth or session handoff pages
- `ActivityIndicator` as the **sole/main screen content** (flex:1 container, early return) without any navigation component (e.g., `HeaderWithBackButton`, close button) **in the same return statement or conditional branch**

**DO NOT flag if:**

**For `FullscreenLoadingIndicator`:**
- `shouldUseGoBackButton` is omitted and no navigation is rendered in the same return statement or conditional branch. The prop defaults to `true`, so do not ask to add `shouldUseGoBackButton` or `shouldUseGoBackButton={true}`
- An explicit `shouldUseGoBackButton` (or `={true}`) is removed. This does not change behavior
- Visibility is controlled by `FullScreenLoaderContext`

**For `ActivityIndicator`:**
- Used within interactive UI elements (buttons, list items, cards) where user can still interact with surrounding navigation

**Search Patterns** (hints for reviewers):
- `FullscreenLoadingIndicator`
- `FullScreenLoadingIndicator`
- `shouldUseGoBackButton`
- `ActivityIndicator`

view run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It flags a FullscreenLoadingIndicator without shouldUseGoBackButton={false} when a header with a back button renders in the same tree. Otherwise the loader stacks a second "Go Back" button over the header's back button.

@MelvinBot I corrected sentence:

when it doesn't make sense to go back

As FullScreenLoadingIndicator should not be used in the first place when the page already has header back button

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I changed the proposed rule to match your correction:

  • Header with a back button in the same tree: flag the FullscreenLoadingIndicator itself and suggest ActivityIndicator. The rule no longer offers shouldUseGoBackButton={false} as a fix for this case. This matches the current rule on main.
  • shouldUseGoBackButton={false}: flag a missing ={false} only when it doesn't make sense to go back, such as global overlays, sign-in or sign-out transitions, and auth handoff pages.
  • Unchanged: the rule still doesn't flag a missing or removed shouldUseGoBackButton, because the prop defaults to true.

I still can't edit files under .claude/, so I couldn't open the PR. An engineer needs to replace .claude/skills/app-coding-standards/rules/ui-1-correct-loading-indicator.md with the file below. The ={false} example is LogOutPreviousUserPage.tsx:105, which already does this on main.

Revised ui-1-correct-loading-indicator.md
---
ruleId: UI-1
title: Use the correct loading indicator based on navigation context
---

## [UI-1] Use the correct loading indicator based on navigation context

### Reasoning

If loading hangs, users need an escape route. When navigation (back button, close) is visible alongside the loader, users can escape - use `ActivityIndicator`. `FullscreenLoadingIndicator` doesn't belong there: it is absolutely positioned, draws over the header, and adds a second "Go Back" button. When no navigation is visible, users are trapped - use `FullscreenLoadingIndicator`, which shows an emergency "Go Back" button after a timeout. `shouldUseGoBackButton` defaults to `true`, so omit it. Pass `shouldUseGoBackButton={false}` only when it doesn't make sense to go back, such as global overlays, sign-in/sign-out transitions, and auth or session handoff pages.

### Incorrect

```tsx
// Header and FullscreenLoadingIndicator in SAME return - use ActivityIndicator
<ScreenWrapper>
    <HeaderWithBackButton title="Settings" />
    <FullscreenLoadingIndicator />
</ScreenWrapper>

// Sign-out transition page - going back makes no sense, so turn the "Go Back" button off
function LogOutPreviousUserPage() {
    return <FullscreenLoadingIndicator />;
}

// ActivityIndicator as sole content without navigation - use FullscreenLoadingIndicator
function AuthLoadingPage() {
    return (
        <View style={[styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter]}>
            <ActivityIndicator size="large" />
        </View>
    );
}
```

### Correct

```tsx
// No navigation in return - FullscreenLoadingIndicator shows the "Go Back" button by default
function ReportLoadingPage() {
    return <FullscreenLoadingIndicator />;
}

// Loader and navigation in DIFFERENT returns - OK to use FullscreenLoadingIndicator
function SettingsPage() {
    if (isLoading) {
        return <FullscreenLoadingIndicator />;
    }
    return (
        <ScreenWrapper>
            <HeaderWithBackButton title="Settings" />
            <Content />
        </ScreenWrapper>
    );
}

// Header visible during loading - use ActivityIndicator
function SettingsPage() {
    return (
        <ScreenWrapper>
            <HeaderWithBackButton title="Settings" />
            {isLoading ? (
                <View style={[styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter]}>
                    <ActivityIndicator size="large" />
                </View>
            ) : (
                <Content />
            )}
        </ScreenWrapper>
    );
}

// Going back makes no sense - turn the "Go Back" button off
function LogOutPreviousUserPage() {
    return <FullscreenLoadingIndicator shouldUseGoBackButton={false} />;
}
```

---

### Review Metadata

Flag ONLY when ANY of these patterns is found:

- `FullscreenLoadingIndicator` and `HeaderWithBackButton` (or other navigation like close button) are **both under the same JSX tree** (not separated by conditionals). Suggest `ActivityIndicator` instead
- `FullscreenLoadingIndicator` without `shouldUseGoBackButton={false}` when **it doesn't make sense to go back**: global overlays, sign-in/sign-out transitions, auth or session handoff pages
- `ActivityIndicator` as the **sole/main screen content** (flex:1 container, early return) without any navigation component (e.g., `HeaderWithBackButton`, close button) **in the same return statement or conditional branch**

**DO NOT flag if:**

**For `FullscreenLoadingIndicator`:**
- `shouldUseGoBackButton` is omitted and no navigation is rendered in the same return statement or conditional branch. The prop defaults to `true`, so do not ask to add `shouldUseGoBackButton`
- An explicit `shouldUseGoBackButton` (or `={true}`) is removed. This does not change behavior
- Visibility is controlled by `FullScreenLoaderContext`

**For `ActivityIndicator`:**
- Used within interactive UI elements (buttons, list items, cards) where user can still interact with surrounding navigation

**Search Patterns** (hints for reviewers):
- `FullscreenLoadingIndicator`
- `FullScreenLoadingIndicator`
- `shouldUseGoBackButton`
- `ActivityIndicator`

view run

}

if (decision === 'notFound') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,7 @@ function ConnectionsMergePageBase({policyID, category, cards, shouldBeBlocked}:
fullPageNotFoundViewProps={{onBackButtonPress: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))}}
>
{isFetchingConnections ? (
<FullScreenLoadingIndicator
shouldUseGoBackButton
style={styles.flex1}
/>
<FullScreenLoadingIndicator style={styles.flex1} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ UI-1 (docs)

This FullScreenLoadingIndicator is rendered inside AccessOrNotFoundWrapper, which renders its children without any surrounding navigation (header/back button). In the isFetchingConnections branch the loader is the only visible content, so removing shouldUseGoBackButton leaves the user with no escape route if the connections fetch hangs. The prop is being migrated to become the default and must be set explicitly for now.

Restore the prop:

<FullScreenLoadingIndicator
    shouldUseGoBackButton
    style={styles.flex1}
/>

Reviewed at: 041f4ff | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

) : (
<ConnectionsMergePageBaseContent
policyID={policyID}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,7 @@ function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageP
fullPageNotFoundViewProps={{onBackButtonPress: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))}}
>
{isLoading ? (
<FullScreenLoadingIndicator
shouldUseGoBackButton
style={styles.flex1}
/>
<FullScreenLoadingIndicator style={styles.flex1} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ UI-1 (docs)

This FullScreenLoadingIndicator is rendered inside AccessOrNotFoundWrapper, which renders its children with no surrounding navigation. The header/back button only appears in the non-loading (ScreenWrapper) branch, so during the isLoading branch there is no navigation visible. Removing shouldUseGoBackButton traps the user with no escape route if loading hangs. The prop is being migrated to become the default and must be set explicitly for now.

Restore the prop:

<FullScreenLoadingIndicator
    shouldUseGoBackButton
    style={styles.flex1}
/>

Reviewed at: 041f4ff | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

) : (
<ScreenWrapper
testID="ConnectionsReceiptPartnersPage"
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import {render} from '@testing-library/react-native';

import ActivityIndicator from '@components/ActivityIndicator';
import DynamicFormFlow from '@components/DynamicForm';
import type {DynamicFormLayout, DynamicFormSubPageProps} from '@components/DynamicForm/types';
import {getListItems, getListItemSensitiveKey} from '@components/DynamicForm/utils/listItems';
import type {FormValue} from '@components/Form/types';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle';

import useSubPage from '@hooks/useSubPage';
import type {SubPageProps} from '@hooks/useSubPage/types';
Expand Down Expand Up @@ -44,6 +47,7 @@ jest.mock('@userActions/DynamicForm', () => ({
}));
jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn(), goBack: jest.fn()}));
jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children));
jest.mock('@components/ActivityIndicator', () => jest.fn(() => null));
jest.mock('@components/FullscreenLoadingIndicator', () => jest.fn(() => null));
jest.mock('@components/InteractiveStepWrapper', () => jest.fn((props: {stepNames: string[]; startStepIndex: number}) => mockStepWrapper(props)));
jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () =>
Expand Down Expand Up @@ -429,6 +433,18 @@ describe('DynamicFormFlow', () => {
expect(subPageOptions.skipPages).toEqual([]);
});

it('shows a spinner below the header while it loads, so no second Go Back button covers the header', async () => {
// Given a flow that is redirecting to another page
// When the flow renders
await renderFlow({isRedirecting: true});

// Then the header is drawn with a plain spinner under it instead of a page
expect(HeaderWithBackButtonAndTitle).toHaveBeenCalled();
expect(ActivityIndicator).toHaveBeenCalled();
expect(FullscreenLoadingIndicator).not.toHaveBeenCalled();
expect(mockCurrentPage).not.toHaveBeenCalled();
});

it('sends the user back to the incomplete page without pushing a second copy of it', async () => {
// Given a form whose SSN was lost when the app restarted
const {subPageOptions, onSubmit} = await renderFlow({draft: {firstName: 'Jane', legalType: 'PRIVATE'}});
Expand Down
Loading