Skip to content

Remove children from AnimatedCollapsible if isExpanded false#72338

Merged
luacmartins merged 11 commits into
Expensify:mainfrom
callstack-internal:perf/report-transaction-rows
Oct 15, 2025
Merged

Remove children from AnimatedCollapsible if isExpanded false#72338
luacmartins merged 11 commits into
Expensify:mainfrom
callstack-internal:perf/report-transaction-rows

Conversation

@LukasMod

@LukasMod LukasMod commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

Explanation of Change

Optimized rendering performance in the To-do reports views (eg. Submit, Pay). Previously, all transaction rows for each report were rendered upfront, even if hidden, causing significant overhead (especially for high-value accounts). The new approach delays rendering transaction rows until the user expands a report and adds Show more logic similar to other transaction groups (Accounting lists like Unapproved cash) if there is more data. Pagination size set to 20 for To-do reports.

Fixed Issues

$ #72025
PROPOSAL:

Tests

  • Verify that no errors appear in the JS console
  1. Import onyx-state containing lots of reports data:
    onyx-state.txt
  2. Go to Reports.
  3. Change between different types of reports (eg. Submit/Pay).
  4. Verify that the lists are expandable.
  5. Check if there is "Show more" button in items with > 20 items (

Offline tests

QA Steps

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
android-native.mov
Android: mWeb Chrome
android-chrome.mov
iOS: Native
ios-native.mov
iOS: mWeb Safari
ios-safari.mov
MacOS: Chrome / Safari
web-chrome.mov
web-safari.mov
MacOS: Desktop
desktop.mov

@codecov

codecov Bot commented Oct 10, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 1.16279% with 85 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...thSections/Search/TransactionGroupListExpanded.tsx 1.26% 78 Missing ⚠️
src/components/AnimatedCollapsible/index.tsx 0.00% 4 Missing ⚠️
...stWithSections/Search/TransactionGroupListItem.tsx 0.00% 3 Missing ⚠️
Files with missing lines Coverage Δ
src/CONST/index.ts 85.84% <ø> (ø)
src/styles/index.ts 45.35% <ø> (ø)
...stWithSections/Search/TransactionGroupListItem.tsx 1.81% <0.00%> (+1.17%) ⬆️
src/components/AnimatedCollapsible/index.tsx 6.66% <0.00%> (+1.11%) ⬆️
...thSections/Search/TransactionGroupListExpanded.tsx 1.26% <1.26%> (ø)

... and 12 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@LukasMod LukasMod force-pushed the perf/report-transaction-rows branch from fd6b0b0 to 11c1776 Compare October 13, 2025 12:01
@LukasMod LukasMod marked this pull request as ready for review October 13, 2025 12:33
@LukasMod LukasMod requested review from a team as code owners October 13, 2025 12:33
@melvin-bot melvin-bot Bot requested review from ikevin127 and removed request for a team October 13, 2025 12:33
@melvin-bot

melvin-bot Bot commented Oct 13, 2025

Copy link
Copy Markdown

@ikevin127 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@JS00001 JS00001 self-requested a review October 13, 2025 12:50
@shawnborton

Copy link
Copy Markdown
Contributor

Just curious, why are all of the Submit buttons disabled in your videos above?

@JS00001 JS00001 left a comment

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.

A few small comments, this feels really nice on larger accounts, great work

});
}, [groupItem.transactionsQueryJSON, newTransactionID, transactionsSnapshot?.search?.offset, isExpanded]);

useEffect(() => {

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.

We shouldnt need an effect for this, can we move this logic into handleToggle please?

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.

Ah, of course! Done

<Button
text={translate('common.showMore')}
onPress={() => {
if (isGroupByReports) {

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.

NAB can we move this logic out of the JSX and as a 'showMore' method?

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.

Sure. Done

@LukasMod

LukasMod commented Oct 13, 2025

Copy link
Copy Markdown
Contributor Author

@shawnborton It's because I use imported onyx state with heavy data here. Using the imported state automatically switches the app to offline mode (there is red alert with indicator at the bottom)

@LukasMod LukasMod requested a review from JS00001 October 13, 2025 14:28
@luacmartins luacmartins self-requested a review October 13, 2025 15:51
@ikevin127

ikevin127 commented Oct 13, 2025

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified tests pass on all platforms & I tested again on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
android-hybrid.mp4
Android: mWeb Chrome
android-mweb.mp4
iOS: HybridApp
ios.mov
iOS: mWeb Safari
ios-mweb.mov
MacOS: Chrome / Safari
web.mov
MacOS: Desktop
desktop.mov

@github-actions

Copy link
Copy Markdown
Contributor

🚧 @mountiny has triggered a test Expensify/App build. You can view the workflow run here.

@github-actions

This comment has been minimized.

import ReportListItemHeader from './ReportListItemHeader';
import WithdrawalIDListItemHeader from './WithdrawalIDListItemHeader';

const TRANSACTIONS_PAGE_SIZE = 20;

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.

We usually define these constants in COST.ts. Additionally, other groups have a 50 item limit. So let's make sure they are all consistent.

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 moved that to CONST.TRANSACTION.RESULTS_PAGE_SIZE but left it at 20 for now, as 50 seems too much for smooth animation to handle. We can easily adjust it later.

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.

Discussing in Slack, but I think 20 should be enough for this case and I dont think we need to only have one const for this as the usecases are not the same. Design team is also in the thread so we can discuss there https://expensify.slack.com/archives/C05LX9D6E07/p1760454907635449?thread_ts=1760397163.992789&cid=C05LX9D6E07

Comment on lines +408 to +421
{shouldDisplayShowMoreButton && !shouldDisplayLoadingIndicator && (
<View style={[styles.w100, styles.flexRow, isLargeScreenWidth && styles.pl10]}>
<Button
text={translate('common.showMore')}
onPress={onShowMoreButtonPress}
link
shouldUseDefaultHover={false}
isNested
medium
innerStyles={[styles.ph3]}
textStyles={[styles.fontSizeNormal]}
/>
</View>
)}

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.

🟡 UX when offline: Show more appears and is clickable but is a noop.

Why it matters:
◦ For non-grouped-by-report results, the button shows and is interactive while offline, but pressing it does nothing. This is confusing UX and wastes user taps.

Fix options:
◦ Disable the button with a tooltip like translate('common.offline'), or
◦ Hide the button when offline for server-paginated lists.

@LukasMod LukasMod Oct 14, 2025

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.

Right, the button will now be hidden for non-grouped-by-report cases 👍 Fixed

Comment on lines +98 to +103
const visibleTransactions = useMemo(() => {
if (isGroupByReports) {
return transactions.slice(0, transactionsVisibleLimit);
}
return transactions;
}, [transactions, transactionsVisibleLimit, isGroupByReports]);

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.

🟢 Minor optimization: only compute visibleTransactions when expanded.

Why it matters:
◦ Slicing and mapping small arrays is cheap, but for very large lists this work can add up when items are not expanded.

Fix:
◦ Compute visibleTransactions based on isExpanded for grouped-by-report so we do no extra work when collapsed.

Suggested change
const visibleTransactions = useMemo(() => {
if (isGroupByReports) {
return transactions.slice(0, transactionsVisibleLimit);
}
return transactions;
}, [transactions, transactionsVisibleLimit, isGroupByReports]);
const visibleTransactions = useMemo(() => {
if (!isExpanded && isGroupByReports) {
return []; // nothing rendered when collapsed
}
if (isGroupByReports) {
return transactions.slice(0, transactionsVisibleLimit);
}
return transactions;
}, [transactions, transactionsVisibleLimit, isGroupByReports, isExpanded]);

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.

Good catch! I left if (!isExpanded) to works for both cases.

? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE
: undefined;

const renderExpandedContent = () => {

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.

🟢 Consistency: renderExpandedContent is recreated on each render.

Why it matters:
◦ Not a functional bug; just a small re-render optimization. Since it closes over a lot of state, memoizing it provides limited benefit unless you pass it down. Optional.

• Optionally wrap in useCallback if you observe measurable re-renders.

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 decided to take it a step further with idea from your comment about isExpanded condition. I moved this logic into separate component, so all unnecessary functions are not called when the content is hidden. It improved list switching by another 50ms in my tests (from ~800ms to ~750ms). And it's also easier to follow up with smaller components now. But I didn't notice any rerender benefits so far from this move, it's the same with all solutions I tried like with useCallback.

const isAnimating = useSharedValue(false);
const hasExpanded = useSharedValue(false);
const isExpandedFirstTime = useRef(false);
const hasExpanded = useSharedValue(isExpanded);

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.

🔴 Permanent overflow: 'hidden' can clip content that should escape the container after animations finish.
ℹ️ While the issue is not visible in our testing steps, this is how I tested / found this issue:

  • in the TransactionGroupListItem.tsx component where we map / render visibleTransactions I added an extra piece of absolute-positioned UI (a badge)
Code spoiler
{visibleTransactions.map((transaction) => (
    <OfflineWithFeedback
        pendingAction={transaction.pendingAction}
        key={transaction.transactionID}
    >
        <View style={{position: 'relative'}}>
            <TransactionItemRow
                report={transaction.report}
                transactionItem={transaction}
                violations={getTransactionViolations(transaction, violations)}
                isSelected={!!transaction.isSelected}
                dateColumnSize={dateColumnSize}
                amountColumnSize={amountColumnSize}
                taxAmountColumnSize={taxAmountColumnSize}
                shouldShowTooltip={showTooltip}
                shouldUseNarrowLayout={!isLargeScreenWidth}
                shouldShowCheckbox={!!canSelectMultiple}
                onCheckboxPress={() => onCheckboxPress?.(transaction as unknown as TItem)}
                columns={currentColumns}
                onButtonPress={() => {
                    openReportInRHP(transaction);
                }}
                style={[styles.noBorderRadius, shouldUseNarrowLayout ? [styles.p3, styles.pt2] : [styles.ph3, styles.pv1Half], isGroupByReports && styles.pr10]}
                isReportItemChild
                isInSingleTransactionReport={groupItem.transactions.length === 1}
                areAllOptionalColumnsHidden={areAllOptionalColumnsHidden}
            />
            {/* Debug badge that extends outside the row/container */}
            <View
                testID="overflowBadge"
                style={{
                    position: 'absolute',
                    bottom: -24, // extend beyond the container
                    right: 16,
                    width: 90,
                    height: 24,
                    backgroundColor: 'red',
                    alignItems: 'center',
                    justifyContent: 'center',
                }}
            >
                <Text style={{color: 'white'}}>Badge</Text>
            </View>
        </View>
    </OfflineWithFeedback>
))}

Why it matters:

  • Components inside the collapsible (e.g., tooltips, popovers, absolute-positioned buttons/menus) may be visually clipped if overflow stays hidden after expand. Previously, overflow was set to 'hidden' only while animating, and then 'visible' again.
  • Concrete example: If a row inside the collapsible opens a context menu or a tooltip that extends beyond the container’s bounds, it will be cut off, especially on mobile (strict iOS: Native) where these overlays frequently extend beyond their parent.

Fix approach:

  • Compute animation progress and only set overflow: 'hidden' while the height is mid-transition. Once fully expanded or fully collapsed, set it to 'visible' (expanded) or 'hidden' (collapsed).
  • This avoids storing an extra isAnimating shared value and keeps logic declarative.

Example fix

// Replace the withTiming(height)-only approach with a normalized progress
// so you can infer "isAnimating" from progress.
const expandedSV = useSharedValue(isExpanded);

useEffect(() => {
    expandedSV.value = isExpanded;
}, [isExpanded, expandedSV]);

const progress = useDerivedValue(() => {
    // 0 = collapsed, 1 = expanded
    return withTiming(expandedSV.value ? 1 : 0, {duration, easing});
}, [duration]);

const contentAnimatedStyle = useAnimatedStyle(() => {
    const h = contentHeight.value * progress.value;
    const isAnimating = progress.value > 0 && progress.value < 1;
    const progressVisibility = progress.value === 1 ? 'visible' : 'hidden';
    return {
        height: h,
        overflow: isAnimating ? 'hidden' : progressVisibility,
    };
});

Results

Before After
before after

@LukasMod LukasMod Oct 14, 2025

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.

The animation looks the same with overflow: "visible". I'll skip this logic and not manipulate that style at all, leaving it at the default. Final effect is like "After" in your comment.
Screenshot 2025-10-14 at 15 46 21

const isAnimating = useSharedValue(false);
const hasExpanded = useSharedValue(false);
const isExpandedFirstTime = useRef(false);
const hasExpanded = useSharedValue(isExpanded);

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.

🟡 Conditional unmounting can cause “first expand” jank and misses the “keep-mounted-while-collapsing” behavior

In addition to the ☝️ above comment:

Why it matters:

  • With mount-on-expand, the very first expand has to measure content. animatedHeight tries to animate to contentHeight, but contentHeight is 0 until onLayout runs. This can lead to a two-phase expand where height goes 0 → 0 → measuredHeight, which feels like a hitch on slower devices.
  • When collapsing, we remove children immediately (conditional render), but we also rely on FadeOut. Reanimated exit animations will run, but the container height is also animating to 0. If we ever remove entering/exiting or it fails on web, the collapse might snap shut visually.

Fix approach:

  • Keep children rendered until the collapse animation (progress) finishes, then unmount. This preserves smooth transitions and avoids measuring hitches.
  • We can keep your FadeIn/FadeOut and still unmount at the end of animation to keep the performance win.

Example fix

const [isRendered, setIsRendered] = React.useState(isExpanded);

useEffect(() => {
    if (isExpanded) {
        setIsRendered(true);
    }
}, [isExpanded]);

// When animation completes to 0, unmount using runOnJS to avoid UI-thread state updates
useAnimatedReaction(
    () => progress.value,
    (p, prev) => {
        if (prev !== undefined && prev > 0 && p === 0) {
            runOnJS(setIsRendered)(false);
        }
    },
    [],
);

// Use isRendered for conditional rendering instead of isExpanded directly
<Animated.View style={[contentAnimatedStyle, contentStyle]}>
    {isRendered ? (
        <Animated.View
            style={styles.stickToTop}
            entering={FadeIn}
            exiting={FadeOut}
            onLayout={(e) => {
                const h = e.nativeEvent.layout.height;
                if (h) {
                    contentHeight.value = h;
                }
            }}
        >
            ...
        </Animated.View>
    ) : null}
</Animated.View>

Here's a complete example with code changes combined from the ☝️ above comment + this comment's suggested fix:

Code spoiler
import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
import Animated, {
    FadeIn,
    FadeOut,
    runOnJS,
    useAnimatedReaction,
    useAnimatedStyle,
    useDerivedValue,
    useSharedValue,
    withTiming,
} from 'react-native-reanimated';
import {easing} from '@components/Modal/ReanimatedModal/utils';
import useThemeStyles from '@hooks/useThemeStyles';

type Props = {
    isExpanded: boolean;
    header: React.ReactNode;
    children: React.ReactNode;
    duration?: number;
    style?: any;
    contentStyle?: any;
};

function AnimatedCollapsible({isExpanded, children, header, duration = 300, style, contentStyle}: Props) {
    const styles = useThemeStyles();

    // Measure + animation state
    const contentHeight = useSharedValue(0);
    const hasMeasured = useSharedValue(false);
    const isExpandedSV = useSharedValue(isExpanded);

    // Keep children mounted while collapsing; unmount only after progress reaches 0
    const [isRendered, setIsRendered] = useState(isExpanded);

    useEffect(() => {
        isExpandedSV.value = isExpanded;
        if (isExpanded) {
            // Mount immediately so we can measure before animating
            setIsRendered(true);
        }
    }, [isExpanded, isExpandedSV]);

    // 0 = collapsed, 1 = expanded; wait for measurement before animating open
    const progress = useDerivedValue(() => {
        const target = isExpandedSV.value && hasMeasured.value ? 1 : 0;
        return withTiming(target, {duration, easing});
    }, [duration]);

    // When collapse animation finishes (progress -> 0), unmount content
    useAnimatedReaction(
        () => progress.value,
        (p, prev) => {
            if (prev !== undefined && prev > 0 && p === 0) {
                runOnJS(setIsRendered)(false);
            }
        },
        [],
    );

    const contentAnimatedStyle = useAnimatedStyle(() => {
        const h = hasMeasured.value ? contentHeight.value * progress.value : 0;
        const isAnimating = progress.value > 0 && progress.value < 1;

        return {
            height: h,
            // Hide overflow during the transition; once fully expanded, allow overflow
            overflow: isAnimating ? 'hidden' : progress.value === 1 ? 'visible' : 'hidden',
        };
    });

    return (
        <View style={style}>
            <View style={[headerStyle, styles.flexRow, styles.alignItemsCenter]}>
                <View style={[styles.flex1]}>{header}</View>
                <PressableWithFeedback
                    onPress={onPress}
                    disabled={disabled}
                    style={[styles.p3, styles.justifyContentCenter, styles.alignItemsCenter, expandButtonStyle]}
                    accessibilityRole={CONST.ROLE.BUTTON}
                    accessibilityLabel={isExpanded ? 'Collapse' : 'Expand'}
                >
                    {({hovered}) => (
                        <Icon
                            src={isExpanded ? Expensicons.UpArrow : Expensicons.DownArrow}
                            fill={hovered ? theme.textSupporting : theme.icon}
                            small
                        />
                    )}
                </PressableWithFeedback>
            </View>
            <Animated.View style={[contentAnimatedStyle, contentStyle]}>
                {isRendered ? (
                    <Animated.View
                        style={styles.stickToTop}
                        entering={FadeIn}
                        exiting={FadeOut}
                        onLayout={(e) => {
                            const h = e.nativeEvent.layout.height;
                            if (h) {
                                contentHeight.value = h;
                                hasMeasured.value = true;
                            }
                        }}
                    >
                        <View style={[styles.pv2, styles.ph3]}>
                            <View style={[styles.borderBottom]} />
                        </View>
                        {children}
                    </Animated.View>
                ) : null}
            </Animated.View>
        </View>
    );
}

export default AnimatedCollapsible;

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 added additional conditions to animatedHeight to skip the initial no-op animation as proposed, but I used contentHeight value to not introduce a new one.
        if (!contentHeight.get()) {
            return 0;
        }
  • I introduced const [isRendered, setIsRendered] = React.useState(isExpanded); as requested, to delay removing the content until the animation finishes.
    I use it as {isExpanded || isRendered ? <... />} to remove the content later, but mount it as soon as possible.
    Without the isExpanded check, there was a weird effect on iOS where the content expanded quickly but rendered slightly too late.
  • I replaced FadeOut/FadeIn with animatedOpacity.get()

@ikevin127

Copy link
Copy Markdown
Contributor

🧪 Testing recommendations

AnimatedCollapsible

  • Unit: Ensure overflow becomes 'visible' after expand and returns to 'hidden' after collapse completes. This can be tested by inspecting computed style via testing-library on web.
  • Integration (mobile): Expand/collapse flows with a child showing a popover/tooltip. Verify no clipping after expansion.

TransactionGroupListItem

  • Unit:
    ▪ Given N > 20 grouped transactions, verify only 20 render initially and that clicking “Show more” renders 40, etc.
    ▪ Verify limit resets to 20 after collapsing and re-expanding.
    ▪ When offline, for non-group-by-reports, verify the Show more button is disabled or hidden (depending on the chosen approach).
  • Integration: Verify “Show more” increments do not trigger re-renders of sibling groups.

@ikevin127

Copy link
Copy Markdown
Contributor

🟢 Completed reviewer checklist.

🔄 Overall the improvement is solid - though I dropped some important comments above targeting both logic and performance + 🧪 testing recommendations around the changes, which should probably be addressed before merging, as while testing: I noticed jankiness in the animations of expanding reports with lots of transactions, as well as further expanding via Show more with lots of transactions.

☝️ The animation jankiness can be observed in my checklist testing videos (I know that things are probably slower on DEV then they will be on production builds).

♻️ I will defer approval until all comments are addressed, in case I need to retest (code changes).

@github-actions

Copy link
Copy Markdown
Contributor

LGTM :feelsgood:. Thank you for your hard work!

@LukasMod

Copy link
Copy Markdown
Contributor Author

@ikevin127 Thanks for the great CR! I’ve replied to all threads and pushed changes.

  • I did some code splitting, so it might look like there are a lot of changes, but most of it is just moving <TransactionGroupListExpandedItem/> into a separate component. This way, the logic only executes when expanded.
  • I’ve also added the fixes to AnimatedCollapsible as requested, with a few small tweaks. I removed overflow: 'hidden' completely since the animation seems to behave the same without it.
  • Pagination for grouped reports is set to 20, changing to 50 was a bit too much. I would leave it 20 for now and adjust it later if there will be need for that.
  • Occasionally, I still encounter a small lag during the animation, but this could be more likely due to the generally heavy Onyx state and all computations behind the scene, as restarting the app usually helps.

I will think about some unit tests for TransactionGroupListItem or TransactionGroupListExpanded tomorrow

@luacmartins luacmartins left a comment

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.

LGTM

@mountiny mountiny left a comment

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.

@ikevin127 Thanks for a thorough review, can you please give it a test after the recent changes? thanks!

@github-actions

Copy link
Copy Markdown
Contributor

🚧 @mountiny has triggered a test Expensify/App build. You can view the workflow run here.

@github-actions

Copy link
Copy Markdown
Contributor

🧪🧪 Use the links below to test this adhoc build on Android, iOS, Desktop, and Web. Happy testing! 🧪🧪
Built from App PR #72338.

Android 🤖 iOS 🍎
https://ad-hoc-expensify-cash.s3.amazonaws.com/android/72338/index.html https://ad-hoc-expensify-cash.s3.amazonaws.com/ios/72338/index.html
Android iOS
Desktop 💻 Web 🕸️
https://ad-hoc-expensify-cash.s3.amazonaws.com/desktop/72338/NewExpensify.dmg https://72338.pr-testing.expensify.com
Desktop Web

👀 View the workflow run that generated this build 👀

@ikevin127 ikevin127 left a comment

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.

@ikevin127 Thanks for a thorough review, can you please give it a test after the recent changes? thanks!

Sure, just did and it LGTM 🟢 Thanks for carefully considering the requested changes / improvements 🚀

@melvin-bot melvin-bot Bot requested a review from JS00001 October 14, 2025 19:17
@luacmartins

Copy link
Copy Markdown
Contributor

Gonna merge this one. We can easily change the number of transactions we display per group

@luacmartins luacmartins merged commit cc145d6 into Expensify:main Oct 15, 2025
27 checks passed
@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@m-natarajan

Copy link
Copy Markdown

@LukasMod @mountiny Could you help with step 1?

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/luacmartins in version: 9.2.32-0 🚀

platform result
🖥 desktop 🖥 success ✅
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@ikevin127

Copy link
Copy Markdown
Contributor

@m-natarajan Click on the file to download, then Settings > Troubleshoot > Import Onyx state and import the file.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/mountiny in version: 9.2.32-6 🚀

platform result
🖥 desktop 🖥 success ✅
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

createAndOpenSearchTransactionThread(transactionItem, iouAction, currentSearchHash, backTo);
return;
}
markReportIDAsExpense(reportID);

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.

Coming from #72178, we need to check if the report is invoice or task, if so, we shouldn't mark it as expense

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.

This PR focused on another issue, logic mentioned is only copied from src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants