Skip to content

Optimize OptionData object for search contexts#67073

Merged
mountiny merged 25 commits intoExpensify:mainfrom
callstack-internal:perf/slim-option-data-object
Aug 29, 2025
Merged

Optimize OptionData object for search contexts#67073
mountiny merged 25 commits intoExpensify:mainfrom
callstack-internal:perf/slim-option-data-object

Conversation

@sosek108
Copy link
Contributor

@sosek108 sosek108 commented Jul 24, 2025

Created SearchOptionData type with only 42 essential properties (down from 50+) for search and list contexts, reducing memory usage by 16% per option. Updated createOption function and related search utilities to use optimized interface while maintaining full OptionData for other contexts.

Explanation of Change

Optimizes memory usage in search and option list contexts by creating a targeted SearchOptionData interface that includes only the properties actually used, reducing memory allocation by 16% per option object.

Fixed Issues

$ #67123
PROPOSAL: #67123

Tests

Search

  1. Open search via magnifying glass icon
  2. See if options displayed have proper information
    1. Look at the main text and alternate text
  3. Write something that will show personal detail
    1. Confirm that main text and alternate text are displayed correctly

Create expense

  1. Via FAB click Create expense
  2. Write anything in amount ang click Next
  3. Click on To field
  4. Confirm that options are correctly displayed
    1. look at both main text and alternate text
    2. confirm that workspaces are properly displayed
    3. confirm that personal details are properly displayed

Offline tests

N/A

QA Steps

Search

  1. Open search via magnifying glass icon
  2. See if options displayed have proper information
    1. Look at the main text and alternate text
  3. Write something that will show personal detail
    1. Confirm that main text and alternate text are displayed correctly

Create expense

  1. Via FAB click Create expense
  2. Write anything in amount ang click Next
  3. Click on To field
  4. Confirm that options are correctly displayed
    1. look at both main text and alternate text
    2. confirm that workspaces are properly displayed
    3. confirm that personal details are properly displayed

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 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 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.mov
Android: mWeb Chrome
iOS: Native
ios.mov
Nagranie.z.ekranu.2025-07-24.o.16.44.04.mov
iOS: mWeb Safari
MacOS: Chrome / Safari
web.mov
MacOS: Desktop https://github.com/user-attachments/assets/4c9b89d8-e426-422f-8d15-3d8c2fac5d6e

Created SearchOptionData type with only 42 essential properties (down
from 50+) for search and list contexts, reducing memory usage by 16% per
option. Updated createOption function and related search utilities to
use optimized interface while maintaining full OptionData for other
contexts.
@sosek108 sosek108 changed the title Optimize OptionData memory usage for search contexts Optimize OptionData object for search contexts Jul 25, 2025
@sosek108 sosek108 marked this pull request as ready for review July 25, 2025 08:30
@sosek108 sosek108 requested a review from a team as a code owner July 25, 2025 08:30
@melvin-bot melvin-bot bot requested review from aldo-expensify and removed request for a team July 25, 2025 08:30
@melvin-bot
Copy link

melvin-bot bot commented Jul 25, 2025

@aldo-expensify 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]

@aldo-expensify
Copy link
Contributor

@sosek108 do you have an issue to link to this PR?
Also, I think we should get a C+ to review this first, but I'll do that after I see some context for this PR.

@sosek108
Copy link
Contributor Author

@sosek108 do you have an issue to link to this PR? Also, I think we should get a C+ to review this first, but I'll do that after I see some context for this PR.

Of course I have. #67123

Sorry for confusion

@aldo-expensify
Copy link
Contributor

@sosek108 do you have an issue to link to this PR? Also, I think we should get a C+ to review this first, but I'll do that after I see some context for this PR.

Of course I have. #67123

Sorry for confusion

nice, thank you!

@aldo-expensify aldo-expensify requested review from ikevin127 and removed request for aldo-expensify July 25, 2025 17:48
@ikevin127
Copy link
Contributor

ikevin127 commented Jul 25, 2025

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 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-hybrid.mp4
iOS: mWeb Safari
ios-mweb.mp4
MacOS: Chrome / Safari
web.mov
MacOS: Desktop
desktop.mov

Copy link
Contributor

@ikevin127 ikevin127 left a comment

Choose a reason for hiding this comment

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

(5) Code review completed.


🔄 Moving forward with checklist completion.

* Sort options by a given comparator and return first sorted options.
* Function uses a min heap to efficiently get the first sorted options.
*/
function optionsOrderBy<T = SearchOptionData>(options: T[], comparator: (option: T) => number | string, limit?: number, filter?: (option: T) => boolean | undefined, reversed = false): T[] {
Copy link
Contributor

Choose a reason for hiding this comment

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

NAB: Missing Error Handling - Here are some thoughts on this function based on comprehensive review, noting that some of the concerns might not be valid:

1. Comparator Function Failures

The comparator function is called multiple times without error handling:

// These calls can throw exceptions if comparator fails
if (comparator(option) > comparator(peekedValue)) {
    // ...
}

What could go wrong:
• If option.text is undefined and comparator tries to call .toLowerCase() on it
• If option.lastVisibleActionCreated is an invalid date string
• If the comparator accesses a property that doesn't exist on the option object
• If the comparator receives malformed data that causes runtime errors

Example failure scenario:

const recentReportComparator = (option: SearchOptionData) => {
    // This could throw if private_isArchived is undefined and causes type coercion issues
    // or if lastVisibleActionCreated is not a valid date string
    return `${option.private_isArchived ? 0 : 1}_${option.lastVisibleActionCreated ?? ''}`;
};

// If lastVisibleActionCreated is unexpectedly null or contains invalid characters,
// the string concatenation might result in comparison issues

2. Filter Function Failures

if (filter && !filter(option)) {
    return;
}

What could go wrong:
• Filter function throws an exception when processing malformed option data
• Filter function tries to access properties that don't exist
• Filter function performs expensive operations that timeout or fail

3. Heap Operation Failures

heap.push(option);
heap.pop();
const peekedValue = heap.peek();

What could go wrong:
• Memory allocation failures during heap operations
• Heap corruption due to invalid comparator results
• Stack overflow in recursive heap operations with very large datasets

4. Input Validation Missing (this depends on the filtering function)

options.forEach((option) => {
    // No validation that option is a valid object

What could go wrong:
options array contains null or undefined values
options array is not actually an array
• Individual options are malformed or missing required properties

Real-World Bug Scenarios These Could Cause

Scenario 1: Crash During Search

// User types in search, SearchOptionData has corrupted lastVisibleActionCreated
const corruptedOption = {
    // ... other properties
    lastVisibleActionCreated: "invalid-date-string",
    private_isArchived: undefined // Should be boolean
};

// This causes recentReportComparator to fail
const recentReportComparator = (option: SearchOptionData) => {
    return `${option.private_isArchived ? 0 : 1}_${option.lastVisibleActionCreated ?? ''}`;
    // TypeError: Cannot read property of undefined, or string comparison fails
};

App Impact: Complete crash of search functionality, white screen, user can't search for chats/people.

Scenario 2: Performance Degradation

// Filter function has an expensive operation that fails
const expensiveFilter = (option: SearchOptionData) => {
    // This could throw if text is null/undefined
    return option.text.toLowerCase().includes(searchTerm.toLowerCase());
};

App Impact: Search becomes extremely slow or hangs, making the app unresponsive.

Scenario 3: Inconsistent Results

// Comparator fails for some items but not others
const inconsistentComparator = (option: SearchOptionData) => {
    if (!option.lastVisibleActionCreated) {
        throw new Error("Missing timestamp"); // Only some options fail
    }
    return option.lastVisibleActionCreated;
};

App Impact: Search results are inconsistent, some valid options disappear from results, user can't find their conversations.

Scenario 4: Memory Leaks

// Heap operations fail midway, leaving heap in corrupted state
options.forEach((option) => {
    heap.push(option); // Fails on 500th iteration due to memory issues
    // Heap is now in inconsistent state, subsequent operations fail
});

App Impact: App gradually slows down, eventually crashes due to memory issues.


Comprehensive Error Handling Solution

(use as a guide, only for issues which are actually valid / make sense in real-world scenarios)

Code spoiler
function optionsOrderBy<T = SearchOptionData>(
    options: T[], 
    comparator: (option: T) => number | string, 
    limit?: number, 
    filter?: (option: T) => boolean | undefined, 
    reversed = false
): T[] {
    // Input validation
    if (!Array.isArray(options) || options.length === 0) {
        return [];
    }

    if (typeof comparator !== 'function') {
        console.error('[OptionsListUtils] Invalid comparator function provided');
        return options.slice(0, limit || options.length);
    }

    try {
        Timing.start(CONST.TIMING.SEARCH_MOST_RECENT_OPTIONS);
        const heap = reversed ? new MaxHeap<T>(comparator) : new MinHeap<T>(comparator);
        
        const maxProcessingTime = 5000; // 5 second timeout
        const startTime = Date.now();

        for (const option of options) {
            // Timeout protection
            if (Date.now() - startTime > maxProcessingTime) {
                console.warn('[OptionsListUtils] Processing timeout, returning partial results');
                break;
            }

            // Validate option
            // Note: This is usually done through the filteringFunction, but the one that takes
            // report: SearchOption<Report> as argument is missing some checks
            if (!option || typeof option !== 'object') {
                continue;
            }

            // Safe filter execution
            try {
                if (filter && !filter(option)) {
                    continue;
                }
            } catch (filterError) {
                console.warn('[OptionsListUtils] Filter function failed for option:', filterError);
                continue; // Skip this option, don't break entire operation
            }

            // Safe heap operations
            try {
                if (limit && heap.size() >= limit) {
                    const peekedValue = heap.peek();
                    if (!peekedValue) {
                        // This shouldn't happen with proper heap implementation
                        console.warn('[OptionsListUtils] Heap peek returned null when size >= limit');
                        heap.push(option);
                        continue;
                    }

                    try {
                        if (comparator(option) > comparator(peekedValue)) {
                            heap.pop();
                            heap.push(option);
                        }
                    } catch (comparatorError) {
                        console.warn('[OptionsListUtils] Comparator failed:', comparatorError);
                        // Continue processing other options
                        continue;
                    }
                } else {
                    heap.push(option);
                }
            } catch (heapError) {
                console.error('[OptionsListUtils] Heap operation failed:', heapError);
                // Try to continue with remaining options
                continue;
            }
        }

        // Extract results safely
        const result: T[] = [];
        try {
            while (!heap.isEmpty() && result.length < (limit || Number.MAX_SAFE_INTEGER)) {
                const item = heap.pop();
                if (item) {
                    result.unshift(item); // MinHeap gives smallest first, we want reverse order
                }
            }
        } catch (extractError) {
            console.error('[OptionsListUtils] Failed to extract results from heap:', extractError);
            // Return what we have so far
        }

        Timing.end(CONST.TIMING.SEARCH_MOST_RECENT_OPTIONS);
        return result;
        
    } catch (error) {
        console.error('[OptionsListUtils] Critical error in optionsOrderBy:', error);
        Timing.end(CONST.TIMING.SEARCH_MOST_RECENT_OPTIONS);
        
        // Fallback: return simple sorted subset
        try {
            const filtered = options.filter((option, index) => {
                if (index > 1000) return false; // Limit processing to prevent further issues
                try {
                    return !filter || filter(option);
                } catch {
                    return false;
                }
            });
            return filtered.slice(0, limit || filtered.length);
        } catch {
            // Last resort: return empty array
            return [];
        }
    }
}

cc @aldo-expensify for some eyes on this one as well since it's an impactful function for this PR

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for such deep analysis! I am going to look into that. Also feedback from @aldo-expensify will definitely be handy as well.

Copy link
Contributor

@kacper-mikolajczak kacper-mikolajczak Jul 28, 2025

Choose a reason for hiding this comment

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

  1. Comparator Function Failures

Sorry if I am missing something but from the example you brought, I could not see how it could crash as all the values have fallbacks or are primitives that should be coerced properly. Could you give more insight of what you had in mind? Thanks ❤️

Copy link
Contributor

Choose a reason for hiding this comment

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

  1. Filter Function Failures

Exceptions raised in filter function will bubble up and should be handled by parent-level try/catch block:

            // Safe filter execution
            try {
                if (filter && !filter(option)) {
                    continue;
                }
            } catch {
                continue; // Skip this option, don't break entire operation
            }
            
            ```

Copy link
Contributor

@kacper-mikolajczak kacper-mikolajczak Jul 28, 2025

Choose a reason for hiding this comment

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

  1. Heap Operation Failures & 4. Memory Leaks

In theory, yes. How would you guard against that?

I've looked through the usages of both MinHeap and MaxHeap - they are used inside optionsOrderBy helper. Unless the invocation of this function is closed over and its reference is kept alive, those heaps should be garbage-collected by the runtime GC, making it's quite unlikely to crash due to memory overflow.

Do you know any memory allocation control mechanisms that we are using for heavy memory use-cases in the app right now (preferably those who are persisted between callstack states, i.e. global scope).

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry if I am missing something but from the example you brought, I could not see how it could crash as all the values have fallbacks or are primitives that should be coerced properly.

I think we're good to go here since as you said they have solid fallbacks 👍

Thanks for thoughtfully addressing the comments!

Comment on lines +1953 to +1954
// if maxElements is passed, filter the recent reports by searchString and return only most recent reports (@see recentReportsComparator)
const searchTerms = deburr(searchString ?? '')
Copy link
Contributor

Choose a reason for hiding this comment

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

NAB: Performance Concerns (might not be 100% valid, but thought I should mention it)

The new implementation in getValidOptions performs multiple iterations and filtering operations that could be optimized:

// Lines 1863-1900: Multiple iterations over the same data
const searchTerms = deburr(searchString ?? '')
    .toLowerCase()
    .split(' ')
    .filter((term) => term.length > 0);

const filteringFunction = (report: SearchOption<Report>) => {
    // ... complex filtering logic that runs for every report
};

filteredReports = optionsOrderBy(options.reports, recentReportComparator, maxElements, filteringFunction);

// Then later, another filtering operation:
const {recentReports, workspaceOptions, selfDMOption} = getValidReports(filteredReports, {
    // ... more filtering
});

Problem: The code performs search term processing and filtering multiple times, which could be expensive for large datasets.
Suggestion: Use memoization and combine filtering operations:

const processedSearchTerms = useMemo(() => {
    return deburr(searchString ?? '')
        .toLowerCase()
        .split(' ')
        .filter((term) => term.length > 0);
}, [searchString]);

// Combine filtering operations to reduce iterations
const combinedFilteringFunction = useCallback((report: SearchOption<Report>) => {
    // Combine all filtering logic here
    return isValidReport(report, config) && searchTermsMatch(report, processedSearchTerms);
}, [config, processedSearchTerms]);

Since useMemo and useCallback are not available outside of react components, one can go with manual memoization, using a caching mechanism. This would reduces redundant calculations by returning cached results for previously computed inputs. Here’s how to approach it:

  1. Define a Cache Using Map: If we're expecting complex objects as keys, consider using a WeakMap for garbage collection support - for simpler keys or when you control input lifecycle, a Map suffices.
  2. Generate a Unique Key for Caching: Construct a string or hash that represents the unique parameters for each call to getValidOptions - ensure it captures all relevant input.
  3. Implement Caching Logic: Check the cache before performing computations. If an entry exists, return the cached result; otherwise, compute and cache it.
  4. Clear Cache When Needed: Consider when you'd want to invalidate or clear the cache, such as significant data changes, to ensure data consistency.

Benefits of Manual Memoization

  • Efficiency: Avoids redundant processing by leveraging cached results, reducing computational overhead.
  • Scalability: Improves performance especially in large datasets where filters and computations are expensive.
  • Flexibility: Allows custom logic for cache invalidation and key construction tailored to our performance needs.

Considerations:

  • Memory Usage: Cached data consumes memory. Choose a strategy that balances speed with resource usage, possibly involving LRU (Least Recently Used) cache strategies.
  • Complexity: Ensure the key generation logic is efficient and produces unique identifiers for the parameter sets.
  • Invalidation Strategy: Understand when and why to invalidate the cache to ensure data integrity and freshness.

By implementing memoization thoughtfully, we can achieve meaningful performance improvements, especially in frequent operations with repetitive inputs.

Copy link
Contributor

@kacper-mikolajczak kacper-mikolajczak Jul 28, 2025

Choose a reason for hiding this comment

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

Thanks for raising this! Appreciate you calling out the potential performance concern.

That said, I would suggest we can implement it as a next iteration on working of the performance if actually needed, as current PR volume is already quite big.

As you mentioned yourself, the suggestion lacks data needed to ensure such memoization would benefit us instead of undermining overall results of the PR.

If you agree, I would consider it as a next step after rolling out this PR. Let me know what you think, thanks :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The performence concers are valid and I agree that we can handle them in anouther round of Proposal/Solution

@ikevin127
Copy link
Contributor

TypeScript Checks / typecheck is failing with 2 type errors in the OptionsListUtils.ts file coming from the new logic that was added.

@ikevin127
Copy link
Contributor

ikevin127 commented Jul 26, 2025

Here's a test from iPhone Simulator (this is slower than a real device running the production app):

  • I performed Clear cache and restart then opened search right after via the search magnifying glass
  • I was testing on a high traffic account (more data to be fetched than regular accounts)
cache-clean.mp4

Noting that the data does show up after some delay, which might be API call related, taking a while on iPhone Simulator since after calling Clear cache and restart, the option data has to be fetched again from BE.

On Web it's much faster, barely noticeable (which is what's expected on native production app as well):

Screen.Recording.2025-07-26.at.16.19.57.mov

@ikevin127
Copy link
Contributor

🟢 PR Reviewer Checklist completed.

Awaiting for the author / CME to address the code review related comments before Approving.

@kacper-mikolajczak
Copy link
Contributor

Hi @ikevin127, thanks for the review 👋

I am filling in for @sosek108 as he is on vacation. I am going to resolve conflicts and address your comments.

@kacper-mikolajczak
Copy link
Contributor

❌ TypeScript Checks / typecheck is failing with 2 type errors in the OptionsListUtils.ts file coming from the new logic that was added.

Addressed ✅

Copy link
Contributor

@ikevin127 ikevin127 left a comment

Choose a reason for hiding this comment

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

LGTM: Comments were addressed and changes applied where needed ✅

@aldo-expensify Take it away, let us know your take on some of my previous comments (if you think they require attention).

@melvin-bot melvin-bot bot requested a review from roryabraham July 28, 2025 17:14
Copy link
Contributor

@roryabraham roryabraham left a comment

Choose a reason for hiding this comment

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

Whoa, this PR is huge! It seems (at a first quick pass) to go well beyond the stated P/S. I'm happy to review it, but:

  • There was no discussion of replacing the SuffixUkkonenTree with a MaxHeap
  • I expected to see benchmarks (for a PR whose only claim is a performance improvement, it's essential)
  • I expected to see screenshots in the PR description on all platforms

@sosek108
Copy link
Contributor Author

@roryabraham @ikevin127 All checks are passing now. PR is ready for another review

@sosek108
Copy link
Contributor Author

@roryabraham Kind bump about this PR.

Today I've resolved merge conflicts.

roryabraham
roryabraham previously approved these changes Aug 28, 2025
Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

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

Approving to move this ahead before conflicts come in

@mountiny mountiny merged commit babdfa5 into Expensify:main Aug 29, 2025
21 checks passed
@OSBotify
Copy link
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.

@ChavdaSachin
Copy link
Contributor

This PR has caused: selection is not working properly on reports > filters > from/to pages.

Particularly coz of passing default reportID

src/libs/OptionsListUtils/index.ts
line 747: reportID: report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID),

Why it broke the selection functionality ?
=> due to this check

if (selectedOption.reportID && selectedOption.reportID === option?.reportID) {
return true;
}

Now since string(0) is a truthy value this check is broken.

Solution: Since SearchFiltersParticipantsSelector component only has participants and not reports, this check should have never existed in the first place.
Remove following check.

if (selectedOption.reportID && selectedOption.reportID === option?.reportID) {
return true;
}

cc. @sosek108 @ikevin127 @mountiny

@sosek108
Copy link
Contributor Author

sosek108 commented Sep 1, 2025

@ChavdaSachin

Solution: Since SearchFiltersParticipantsSelector component only has participants and not reports, this check should have never existed in the first place.
Remove following check.

Are you sure? As far I can see, reports are used there for Recents calculation.

The declaration in OptionsListUtils/index.ts should look like this.

const result: SearchOptionData = {
        // Core identification - used in SearchOption context
        reportID: report?.reportID ?? '',

@ChavdaSachin
Copy link
Contributor

I am pretty positive SearchFiltersParticipantsSelector has no reports included as list item.
Since the flow is too complex I haven't dig deeper where exactly reports are being removed but they for sure are being removed.

On the other hand changes you suggested would be a safer option at the moment as the PR is about to hit staging.

@sosek108
Copy link
Contributor Author

sosek108 commented Sep 1, 2025

@ChavdaSachin I've already posted a Proposal #67123 (comment). I hope we can process this quickly

@ChavdaSachin
Copy link
Contributor

Yes I was able to confirm, it is safe to remove the check I suggested.

@ChavdaSachin
Copy link
Contributor

Explanation: getValidOptions function is used on SearchFiltersParticipantsSelector page to fetch the options.
Along with following config

excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
includeCurrentUser: true,

Then Inside getValidOptions function - reports are being filtered here.

filteredReports = optionsOrderBy(options.reports, recentReportComparator, maxElements, filteringFunction);

And filteringFunction here used isValidReport function to filter out invalid options.
return isValidReport(report, {

Notice we are passing the config to isValidReport function which is received from SearchFiltersParticipantsSelector page and that config does not include params such as includeMultipleParticipantReports, includeThreads, includeMoneyRequests .... and hence isValidReport function uses it's default params, and filters out all the reports ultimately.

includeMultipleParticipantReports = false,
includeOwnedWorkspaceChats = false,
includeThreads = false,
includeTasks = false,
includeMoneyRequests = false,
includeReadOnly = true,
transactionViolations = {},
includeSelfDM = false,
includeInvoiceRooms = false,

cc. @sosek108

@OSBotify
Copy link
Contributor

OSBotify commented Sep 2, 2025

🚀 Deployed to staging by https://github.com/mountiny in version: 9.2.1-0 🚀

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

@OSBotify
Copy link
Contributor

OSBotify commented Sep 5, 2025

🚀 Deployed to production by https://github.com/arosiclair in version: 9.2.1-20 🚀

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

if (result.private_isArchived) {
result.lastMessageText = translateLocal('reportArchiveReasons.default');
} else {
result.lastMessageText = report.lastMessageText ?? '';
Copy link
Contributor

Choose a reason for hiding this comment

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

Coming from #72537 checklist: This change get incorrect lastMessageText cause by previous logic we get lastMesssageText from getLastMessageTextForReport function so we have to restore this logic to resolve #72537

-       const lastMessageTextFromReport = getLastMessageTextForReport(report, lastActorDetails, undefined, !!result.private_isArchived);
-       let lastMessageText = lastMessageTextFromReport;

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