Skip to content

Commit e69882b

Browse files
authored
Merge pull request Expensify#89060 from callstack-internal/perf/defer-secondary-actions
perf: Defer mount of "More" button in Report
2 parents dabd7b1 + b1530e7 commit e69882b

2 files changed

Lines changed: 63 additions & 19 deletions

File tree

src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account
22
import {hasSeenTourSelector} from '@selectors/Onboarding';
33
import truncate from 'lodash/truncate';
44
import React, {useContext, useEffect} from 'react';
5-
import {InteractionManager} from 'react-native';
5+
import {InteractionManager, View} from 'react-native';
66
import type {ValueOf} from 'type-fest';
7+
import Button from '@components/Button';
78
import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types';
89
import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider';
910
import {KYCWallContext} from '@components/KYCWall/KYCWallContext';
1011
import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown';
1112
import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext';
13+
import NavigationDeferredMount from '@components/NavigationDeferredMount';
1214
import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext';
1315
import type {PopoverMenuItem} from '@components/PopoverMenu';
1416
import {useSearchStateContext} from '@components/Search/SearchContext';
@@ -30,7 +32,10 @@ import usePaymentOptions from '@hooks/usePaymentOptions';
3032
import usePermissions from '@hooks/usePermissions';
3133
import usePolicy from '@hooks/usePolicy';
3234
import useReportIsArchived from '@hooks/useReportIsArchived';
35+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
3336
import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals';
37+
import useStyleUtils from '@hooks/useStyleUtils';
38+
import useThemeStyles from '@hooks/useThemeStyles';
3439
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
3540
import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy';
3641
import {search} from '@libs/actions/Search';
@@ -394,15 +399,45 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo
394399
);
395400
}
396401

402+
function MoneyReportHeaderSecondaryActionsPlaceholder({primaryAction}: {primaryAction: ValueOf<typeof CONST.REPORT.PRIMARY_ACTIONS> | ''}) {
403+
const styles = useThemeStyles();
404+
const StyleUtils = useStyleUtils();
405+
const {translate} = useLocalize();
406+
const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();
407+
const icons = useMemoizedLazyExpensifyIcons(['DownArrow']);
408+
const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth;
409+
const wrapperStyle = shouldDisplayNarrowVersion && !primaryAction ? styles.flex1 : undefined;
410+
// Match the inner styles the real ButtonWithDropdownMenu applies when isSplitButton=false so text placement stays put on swap.
411+
const innerStyles = [StyleUtils.getDropDownButtonHeight(CONST.DROPDOWN_BUTTON_SIZE.MEDIUM), styles.dropDownButtonCartIconView];
412+
return (
413+
<View style={wrapperStyle}>
414+
<Button
415+
text={translate('common.more')}
416+
iconRight={icons.DownArrow}
417+
shouldShowRightIcon
418+
innerStyles={innerStyles}
419+
onPress={() => {}}
420+
/>
421+
</View>
422+
);
423+
}
424+
397425
function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) {
398426
return (
399-
<MoneyReportHeaderSecondaryActionsInner
400-
reportID={reportID}
401-
primaryAction={primaryAction}
402-
isReportInSearch={isReportInSearch}
403-
backTo={backTo}
404-
dropdownMenuRef={dropdownMenuRef}
405-
/>
427+
<NavigationDeferredMount
428+
placeholder={<MoneyReportHeaderSecondaryActionsPlaceholder primaryAction={primaryAction} />}
429+
// RHPReportScreen remounts this tree on setParams arrow-nav without firing a transition,
430+
// so we must not wait for one — see https://github.com/Expensify/App/issues/88931.
431+
waitForUpcomingTransition={false}
432+
>
433+
<MoneyReportHeaderSecondaryActionsInner
434+
reportID={reportID}
435+
primaryAction={primaryAction}
436+
isReportInSearch={isReportInSearch}
437+
backTo={backTo}
438+
dropdownMenuRef={dropdownMenuRef}
439+
/>
440+
</NavigationDeferredMount>
406441
);
407442
}
408443

src/components/NavigationDeferredMount.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,44 @@
1-
import {startTransition, useEffect, useState} from 'react';
1+
import {startTransition, useEffect, useRef, useState} from 'react';
22
import type {ReactNode} from 'react';
33
import TransitionTracker from '@libs/Navigation/TransitionTracker';
44

55
type NavigationDeferredMountProps = {
66
/** Shown until `children` hydrate. Render something cheap with stable sizing to avoid layout jumps. */
77
placeholder?: ReactNode;
88

9-
/** The tree to defer. Mounted as a non-urgent transition after the in-flight (or upcoming) navigation transition ends. */
9+
/** The tree to defer. Mounted as a non-urgent transition after any active navigation transition ends. */
1010
children: ReactNode;
11+
12+
/**
13+
* If true, additionally waits for the next navigation transition to start (and then complete) before mounting.
14+
* This is the natural mode for screens reached via real navigation (push/replace) — the placeholder stays
15+
* up through the full animation. Default: true.
16+
*
17+
* Pass `false` when the consumer can be remounted by a parent without a navigation transition firing
18+
* (e.g. a parent that uses `key={someParam}` and changes the key via `setParams`). In that case, the
19+
* upcoming transition never starts and the safety timeout would make the placeholder visible (and
20+
* unresponsive) for up to `MAX_TRANSITION_START_WAIT_MS`.
21+
*/
22+
waitForUpcomingTransition?: boolean;
1123
};
1224

1325
/**
1426
* Gates a heavy subtree behind navigation transition completion via `TransitionTracker`, so the tree
15-
* hydrates only after the nav animation has finished. The swap is wrapped in `startTransition` so React
16-
* treats the hydrate as non-urgent and can yield to user input.
27+
* hydrates only after any in-flight nav animation has finished. The swap is wrapped in `startTransition`
28+
* so React treats the hydrate as non-urgent and can yield to user input.
1729
*
1830
* Use it for: heavy subtrees that mount as part of a navigation transition (report headers, page-level
1931
* actions, dropdowns rendered on a freshly navigated screen) where the hydrate risks competing with the
2032
* nav animation frame budget.
21-
*
22-
* Do NOT use it for: subtrees that mount in response to user interaction after navigation has settled
23-
* (modals, accordions, popovers opened on tap). If no transition is in flight or upcoming, this component
24-
* waits for the `MAX_TRANSITION_START_WAIT_MS` safety timeout before hydrating, which adds latency with
25-
* no benefit outside navigation contexts.
2633
*/
27-
function NavigationDeferredMount({placeholder = null, children}: NavigationDeferredMountProps): ReactNode {
34+
function NavigationDeferredMount({placeholder = null, children, waitForUpcomingTransition = true}: NavigationDeferredMountProps): ReactNode {
2835
const [isReady, setIsReady] = useState(false);
36+
// One-shot mount-time config — flipping the prop after mount shouldn't retrigger the wait.
37+
const waitForUpcomingTransitionRef = useRef(waitForUpcomingTransition);
2938

3039
useEffect(() => {
3140
const handle = TransitionTracker.runAfterTransitions({
32-
waitForUpcomingTransition: true,
41+
waitForUpcomingTransition: waitForUpcomingTransitionRef.current,
3342
callback: () => startTransition(() => setIsReady(true)),
3443
});
3544
return () => handle.cancel();

0 commit comments

Comments
 (0)