Skip to content

[CP staging] Fix FlashList max-update-depth crashes from OXC React Compiler bailouts#96168

Merged
jasperhuangg merged 11 commits into
mainfrom
rory-fix-oxc-compiler-generic-closure-bailout
Jul 15, 2026
Merged

[CP staging] Fix FlashList max-update-depth crashes from OXC React Compiler bailouts#96168
jasperhuangg merged 11 commits into
mainfrom
rory-fix-oxc-compiler-generic-closure-bailout

Conversation

@roryabraham

@roryabraham roryabraham commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Web FlashList pages were hitting "Maximum update depth exceeded" because OXC's React Compiler often fails to memoize files that Babel still does. Unstable props then churn FlashList rows into an infinite update loop.

The Distance Rates crash was isolated (via bisect) to unstable icon maps from useLazyAsset after an SVG-memoization fix dropped a useMemo. Fixing that alone was not enough for the wider class of OXC bailouts, so this PR also restores dual Babel/OXC auto-memoization for the patterns we hit:

  1. Generic type-param hoisting — OXC bails the whole file (Unsupported declaration type for hoisting). Split into a non-generic *Impl + thin typed wrapper, then drop compensatory manual memo.
  2. Nested HOC/factory components — OXC only discovers module-scope components. Hoist PascalCase *Impls (and for platform stack navigators, replace dynamic useCustomState/useCustomEffects with getCustomState + an Effects component slot).
  3. Related silent OXC skips — e.g. generic casts inside nested hook closures.

Leftovers that still diverge (RNGH ref false positives, intentional useSidebarOrderedReports provider bailout) are tracked in #96209 for cleanup once upstream compilers improve. That issue also tracks unwinding the Impl workarounds when OXC fixes generic hoisting.

Fixed Issues

$ #96161
$ #96207
(probably #96204 too - I had one chance to test it on dev and it worked, but there's no obvious repeatable repro)

Tests

  1. Retest [Due for payment 2026-07-23] Web - Account - App crash when open account tab #96158 (Account tab).
  2. Retest [Due for payment 2026-07-23] web- Scan- Something went wrong after tapping on create expense #96159 (Scan expense).
  3. Retest [Due for payment 2026-07-23] Web - Workspace - App crashes when open Distance rates or Receipt Partner #96161 (Workspace > Distance Rates) — scroll/interact with the list and confirm no crash.
  4. Retest [Due for payment 2026-07-23] Expense - Allow location access modal is missing image #96207 (you will need to use an incognito tab)
  5. Spot-check a few other FlashList-heavy screens (LHN, workspace tables) after a hard refresh.
  6. Confirm the JS console stays clean (no "Maximum update depth exceeded").
  • Verify that no errors appear in the JS console

Offline tests

N/A — restores memoization / compiler eligibility; no new offline-specific behavior.

QA Steps

Same as tests.

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
  • 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

image

…n nested closures

OXC's React Compiler silently fails to compile a component/hook (no build
warning) when a generic type expression referencing the function's own type
parameters (e.g. `as TableHandle<DataType, ColumnKey, FilterKey>`,
`satisfies ActiveSorting<ColumnKey>`) appears inside a nested closure passed
to a hook. Babel's compiler handles the identical code fine, so this
regressed after #93980 swapped OXC in for the web build.

Extract the offending expression into a top-level helper function in each
affected file (Table.tsx, sorting.ts, filtering.ts, useOnyx.ts) so OXC
compiles and memoizes them again. Verified with
`react-compiler-compliance-check check-changed`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/components/Picker/BasePicker.tsx 81.81% <100.00%> (+0.20%) ⬆️
...omponents/Search/FilterComponents/SingleSelect.tsx 66.66% <100.00%> (+0.87%) ⬆️
.../Search/SearchList/ListItem/BaseListItemHeader.tsx 100.00% <100.00%> (ø)
...h/SearchList/ListItem/TransactionGroupListItem.tsx 74.67% <100.00%> (+0.16%) ⬆️
src/components/SelectionList/BaseSelectionList.tsx 83.11% <100.00%> (+0.11%) ⬆️
...ListWithSections/BaseSelectionListWithSections.tsx 92.85% <100.00%> (+0.07%) ⬆️
...onents/SelectionList/hooks/useFlattenedSections.ts 100.00% <100.00%> (ø)
src/components/Table/middlewares/filtering.ts 96.29% <100.00%> (+0.29%) ⬆️
...sactionItemRow/EditableCell/usePopoverEditState.ts 88.46% <100.00%> (ø)
src/components/createOnyxContext.tsx 92.85% <100.00%> (+1.19%) ⬆️
... and 52 more
... and 119 files with indirect coverage changes

06007ef ("Fix Account tab crash from OXC memoizing SVG elements")
fixed a real bug (React Compiler under OXC was caching rendered <svg />
elements instead of component references) but, in doing so, replaced the
useMemo-wrapped icon map construction in useMemoizedLazyIllustrations and
useMemoizedLazyExpensifyIcons with a plain unmemoized loop. That meant every
render produced a brand-new icons object, even when nothing relevant had
changed.

Table row components (e.g. WorkspaceDistanceRatesTableRow, rendered per-row
inside FlashList) call useMemoizedLazyExpensifyIcons on every render, so the
unstable return value fed into FlashList's own re-render/measurement cycle,
eventually tripping React's "Maximum update depth exceeded" limit on pages
like Workspace > Distance Rates.

Found via git bisect across ~1300 commits between the last production
release and main, isolating this exact commit as the regression. Wrap the
icon map construction back in useMemo (keeping the resolveIconComponent fix
intact) so the returned object is stable again when its inputs don't change.

Verified: 3 consecutive clean reproductions on the Distance Rates page with
no console errors, and confirmed the fix is present in the served bundle.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham roryabraham changed the title Fix OXC React Compiler silent bailout on generic casts in nested closures Fix FlashList infinite render loop on Distance Rates page + related OXC compiler bailouts Jul 15, 2026
roryabraham and others added 2 commits July 15, 2026 09:56
…iles identically

Table.tsx, sorting.ts, filtering.ts, and useOnyx.ts no longer silently
bail out of React Compiler auto-memoization on either compiler, and CI
now guards against future regressions. Drop the useMemo/useCallback/
genericMemo workarounds so the compiler is the single source of truth
for memoization here, verified by inspecting the compiled output on
both compilers and Playwright-testing the sorting/filtering behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
OXC bails on const TName / TName[number] inside hook bodies, so move
logic into non-generic impls and stabilize inline importFn via useState.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham roryabraham changed the title Fix FlashList infinite render loop on Distance Rates page + related OXC compiler bailouts Fix infinite render loops on Distance Rates page + related OXC compiler bailouts Jul 15, 2026
OXC React Compiler skips memoization for files with type params
("Unsupported declaration type for hoisting"), which caused web
infinite update loops. Split generic hooks/components into non-
generic Impl + thin typed wrappers and drop genericMemo.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham

Copy link
Copy Markdown
Contributor Author

OXC hoisting sweep landed

Applied the non-generic *Impl + thin typed wrapper pattern to all 31 babel-only files that failed with Todo: Unsupported declaration type for hoisting, and removed genericMemo / gap-only manual memo once both compilers memoize.

Audit

  • Before: 53 babel-only (31 hoisting)
  • After: 22 babel-only, 0 hoisting remaining (HOC/with* + refs-during-render leftovers out of scope)

Playwright retest

Issue Result
#96158 Account tab Pass — /settings/profile loads, no Maximum update depth
#96159 Create scan expense Pass — Scan create dialog opens, no Uh-oh
#96161 Distance rates Pass — page loads with Default Rate, no crash
#96161 Receipt partners Feature toggle did not persist on Collect workspace in local; no Maximum update depth observed when navigating

Cleanup tracking

#96209 (Monthly) — unwind Impl workarounds when oxc ships a hoisting fix.

roryabraham and others added 4 commits July 15, 2026 13:12
OXC only discovers module-scope components, so nested with*
HOCs got zero web memoization. Hoist Impls, dual-compile
Button/AnimatedFlatList, and delete unused genericMemo.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fix easy lint nits; widen no-unsafe-type-assertion baseline
for unavoidable casts from generic/unknown Impl splits.

Co-authored-by: Cursor <cursoragent@cursor.com>
ReturnType of the unknown-payload Impl widened SharedValue and broke
ActionSheetAwareScrollView. useMemoizedLazyAsset intentionally keeps
the first importFn; update the unit test to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hoist PlatformNavigatorImpl to module scope and replace dynamic hook
options with getCustomState + Effects so OXC can auto-memoize.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham roryabraham changed the title Fix infinite render loops on Distance Rates page + related OXC compiler bailouts Fix FlashList max-update-depth crashes from OXC React Compiler bailouts Jul 15, 2026
@melvin-bot

melvin-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

Hey! I see that you made changes to our Form component. Make sure to update the docs in FORMS.md accordingly. Cheers!

@roryabraham
roryabraham requested a review from jasperhuangg July 15, 2026 21:08
@roryabraham
roryabraham marked this pull request as ready for review July 15, 2026 21:08
@roryabraham
roryabraham requested review from a team as code owners July 15, 2026 21:08
@melvin-bot
melvin-bot Bot requested review from heyjennahay and situchan and removed request for a team and situchan July 15, 2026 21:08
@melvin-bot

melvin-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

@situchan 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]

@melvin-bot
melvin-bot Bot requested review from a team and Pujan92 July 15, 2026 21:08
resolveIconComponent was replacing pre-rendered elements with empty
PlaceholderIcon (including host <svg>), which blanked illustrations
like the location permission modal. Wrap those elements as components
and drop the useMemo fusion that reintroduced the Account-tab crash.

Co-authored-by: Cursor <cursoragent@cursor.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 783123e11b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@situchan

situchan commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

AI analysis:

Regression Findings (with reproduction steps)

Fix: "FlashList max-update-depth crashes from OXC React Compiler bailouts"
Scope: 65 files. Below are regressions ranked by confidence, each with repro steps.

================================================================

1. [HIGH] Stale icon when illustration prop changes on a mounted OutcomeScreenBase

================================================================
File: src/hooks/useLazyAsset.ts (useMemoizedLazyAsset)
Also: src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx:68

What changed:
useMemoizedLazyAsset switched from useCallback(() => importFn(), [importFn])
to useState(() => importFn), which captures the FIRST loader forever and never
rebinds. 20 of 21 callers pass a constant string literal (safe). Only
OutcomeScreenBase passes a variable: useMemoizedLazyAsset(() => loadIllustration(illustration)).

User-reproducible steps:

  1. Trigger any Multi-Factor Authentication (MFA) outcome flow that renders an
    OutcomeScreen (e.g. Settings > Security > 2FA, or an Authorize Transaction flow).
  2. Reach an outcome screen that shows an illustration (success/failure art).
  3. Cause the SAME outcome screen instance to re-render with a different illustration
    prop WITHOUT remounting (e.g. a state-machine host that swaps success<->failure
    art in place, or an outcome whose illustration is derived from changing state).
    Expected: the illustration updates to match the new outcome.
    Actual: the illustration stays frozen on the FIRST one shown.
    Note: if every outcome is a distinct navigator screen (fresh mount), this will NOT
    reproduce — so the fix is to confirm all OutcomeScreen hosts remount per illustration.

Verification (dev): add a screen that renders
where x is toggled by a button on the same instance; icon won't change on toggle.

================================================================

2. [MED] ButtonWithDropdownMenu split-button ref detaches/reattaches every render

================================================================
File: src/components/ButtonWithDropdownMenu/index.tsx:527

What changed:
isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor)
became mergeRefs(buttonRef, isSplitButton ? undefined : dropdownAnchor).
mergeRefs() returns a NEW callback ref every render, so the split-button case went
from a stable ref to a fresh callback each render -> React calls ref(null) then
ref(node) every render. No crash (mergeRefs skips undefined/null), but ref churn.

User-reproducible steps:

  1. Open any screen with a split button + dropdown (e.g. an expense report "Submit"
    split button, or Pay-with-dropdown).
  2. Interact so the button's parent re-renders repeatedly (type in a field, toggle
    selection, etc.).
    Expected: stable anchor, no flicker; popover anchors correctly every time.
    Actual: the merged ref reattaches each render; watch for anchor/position glitches
    on the dropdown popover, or a dev-tools ref-churn warning, when the parent re-renders
    rapidly. (Most visible if the button is unmemoized on web.)

================================================================

3. [MED — conditional] useOnyx snapshot result/selector lost memoization (Search screens)

================================================================
File: src/hooks/useOnyx.ts

What changed:
useOnyx stays generic (<TKey, TReturnValue>) but the two useMemo wrappers (snapshot
result tuple and the wrapped selector) were removed. If OXC does NOT memoize this
generic hook, on Search screens the result tuple [keyData, originalResult[1]] and
the selector are fresh references EVERY render -> defeats onyx internal caching ->
over-render, potentially re-triggering "Maximum update depth exceeded".

User-reproducible steps:

  1. Web build. Open Search (Reports/Expenses) with a non-trivial dataset (High-Traffic
    account recommended).
  2. Open the JS console.
  3. Scroll/interact with the Search list and apply/change filters.
    Expected: console stays clean; smooth scroll.
    Actual (if OXC bails): excessive re-renders; in the worst case a
    "Maximum update depth exceeded" error in a Search list component.

Definitive check (engineering): on the PR branch run
npm run react-compiler-compliance-check check-changed
and confirm src/hooks/useOnyx.ts is memoized by BOTH Babel and OXC.
If not memoized -> this is a confirmed regression; restore the useMemo or split into
a non-generic *Impl like the rest of the PR.

================================================================

4. [MED — conditional] Table middlewares + PlatformNavigatorImpl kept generic, lost memoization

================================================================
Files:
src/components/Table/middlewares/filtering.ts (useFiltering<DataType, FilterKey>)
src/components/Table/middlewares/sorting.ts (useSorting<T, ColumnKey>)
src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx
.../createPlatformStackNavigatorComponent/index.native.tsx (PlatformNavigatorImpl)

What changed:
These retain a generic signature (the exact pattern the PR removes elsewhere) but had
their useMemo/useCallback removed. If OXC bails, methods/middleware/state/content
objects churn every render. The outer non-generic wrappers are trivial, so a file-level
compliance pass can be GREEN while the heavy generic Impl is unmemoized.

User-reproducible steps (Table):

  1. Web. Open a workspace table screen (e.g. Workspace > Categories/Company Cards,
    or Category Rules).
  2. Open JS console. Sort a column, type in the table search, toggle filters, and
    scroll.
    Expected: clean console, smooth updates.
    Actual (if OXC bails): visible re-render lag / console churn while sorting/filtering.

User-reproducible steps (Navigator):

  1. Web. Deep-link into a nested screen (e.g. paste a workspace/RHP deep link).
  2. Resize the window across the narrow<->wide breakpoint several times.
  3. Navigate back/forward.
    Expected: correct screen restore, no blank screens, clean console.
    Actual (if OXC bails): extra navigator re-renders; watch for flicker/blank frames.

Definitive check: same compliance-check command as #3 for these four files.

================================================================

5. [LOW — likely a fix, but behavior changed] Button ENTER shortcut is now fully reactive

================================================================
File: src/components/Button/index.tsx (KeyboardShortcutComponent config)

What changed:
Old config memo had intentionally-incomplete deps ([shouldDisableEnterShortcut,
isFocused] with eslint-disable exhaustive-deps), freezing isActive/shouldBubble/
priority against pressOnEnter, isPressOnEnterActive, allowBubble,
enterKeyEventListenerPriority. New plain object tracks all of them, so
useKeyboardShortcut re-subscribes when they change.

User-reproducible steps:

  1. Open a form/modal where a Button uses press-on-enter and isPressOnEnterActive
    can toggle (e.g. a multi-step form, or a modal that enables Enter-submit only when
    a field is valid).
  2. Toggle the condition that flips isPressOnEnterActive WITHOUT changing focus.
  3. Press Enter.
    Expected: Enter submits only when intended.
    Actual: behavior now updates immediately with the condition (old code kept the
    previous active/inactive state until focus changed). Verify Enter-to-submit fires
    exactly when expected on all forms/modals.

================================================================

6. [LOW] Navigator custom effects now run BEFORE screen effects

================================================================
Files: createRightModalNavigator/createRootStackNavigator/createSearchFullscreenNavigator/
createSplitNavigator/createWorkspaceNavigator (useCustomEffects -> component)

What changed:
useCustomEffects was a hook in the navigator (parent) -> ran AFTER child screen
effects. It's now a child sibling rendered first -> its effects
(useNavigationResetOnLayoutChange, usePreserveNavigatorState) run BEFORE the screen
subtree effects.

User-reproducible steps:

  1. Deep-link restore: paste a deep link to a nested screen and load cold.
  2. Layout switch: resize across narrow<->wide breakpoint on a split/workspace/search
    navigator.
  3. Native: perform the back-swipe gesture on a stack.
    Expected: correct state restore/preservation, no blank screens, correct reset on
    layout change.
    Actual: timing of reset/preserve vs screen mount changed; regression-test the three
    flows above for blank screens or lost navigator state.

================================================================
Notes:

@MelvinBot

Copy link
Copy Markdown
Contributor

Regression scan — no blocking regressions found ✅

I reviewed all 66 changed files across six parallel passes (HOC wrappers, report/policy HOCs, PlatformStack navigators, hooks, generic-component splits, list-item headers / SubStepForms / MFA). Every group came back as a clean, mechanical refactor — logic, gate conditions, hook order, effect dependency arrays, ref forwarding, and prop precedence are preserved. The one thing this PR is entirely load-bearing on — both Babel and OXC actually auto-memoizing these files — is confirmed green by CI, so the dropped manual memoization is reliably replaced.

CI evidence for the load-bearing assumption: React Compiler Compliance ✅, typecheck ✅, ESLint ✅, all Jest/Bun tests ✅, and both baseline-perf + branch-perf ✅. (Only validate-perf-tests was still pending at review time.)

Two items are worth flagging — neither is a bug in this PR, but both are latent footguns worth a comment/awareness:

1. useMemoizedLazyAsset now freezes the first importFn (intentional new contract) — Low, but footgun

The only genuine behavioral change in the diff. Before, the hook reacted to a changed loader; now it captures the first one via useState and ignores later ones:

  • useLazyAsset.ts:101-111useCallback(() => importFn(), [importFn])const [stableImportFn] = useState(() => importFn).

This is deliberate (it breaks the setState loop that caused the crash) and documented in the JSDoc, which tells callers to remount with a key. I audited all 15 consumers: every one except OutcomeScreenBase passes a constant literal asset name, so freezing is a no-op for them. The single dynamic consumer — OutcomeScreenBase.tsx:80 (loadIllustration(illustration)) — is correctly given a key={props.illustration} remount wrapper. So no consumer is broken today.

The residual risk is future: a new caller passing a dynamic asset name without a remount key would silently show the stale asset. The JSDoc covers this, but it's an easy trap.

2. Navigator Effects slot commits effects before the content subtree — Low

In the PlatformStack refactor, useCustomEffects moved from running in the parent into a <Effects/> component rendered as a sibling before content (createPlatformStackNavigatorComponent/index.tsx:126-129). This shifts the effect commit order — the navigator's effects (navigation.reset on layout change, preserved-state cache write) now commit before the content subtree's effects rather than after.

Reactivity itself is safe: getCustomState (the plain function that replaced useCustomState) is fed by reactive inputs (useNavigationBuilder state + useResponsiveLayout) read at the top of the parent, so the navigator still re-renders on state/layout change. The effects are self-contained and guarded, so a user-visible symptom is unlikely — but a narrow↔wide layout-resize spot check on Split/Workspace/RootStack navigators is the cheapest way to rule out a one-tick transition glitch.

Everything else verified clean (per-group notes)
  • HOC wrappers (withCurrentUserPersonalDetails, withNavigationFallback, withNavigationTransitionEnd, withToggleVisibilityView, withViewportOffsetTop, createOnyxContext): hoisted *Impls receive every former closure value via props; ref still reaches inner useImperativeHandle consumers (e.g. BaseLoginForm); displayName preserved; withNavigationFallback's fallback context is now a module constant (strictly more stable than the old per-instance useMemo).
  • Report/policy HOCs (withReportAndPrivateNotesOrNotFound, withReportAndReportActionOrNotFound, withFullTransactionOrNotFound, withWritableReportOrNotFound, withPolicy, withPolicyAndFullscreenLoading, withPolicyConnections): NotFound/loading gate conditions and their evaluation order are logically identical (memo→imperative let rewrites are equivalent); no effect dep array changed, so no duplicate openReport/openPolicyAccountingPage calls; injected Onyx props still override incoming props.
  • Hooks (useOnyx, useDebounce, useDebounceNonReactive, useInitialSelection, useStableIndexedHandler, useStepFormSubmit, useSubPage, useWorkletStateMachine, useDebouncedSaveDraft, useFlatListScrollKey, useFlattenedSections, usePopoverEditState): a reviewer ran react-compiler-compliance-check locally and confirmed all 12 report babel=compiled, oxc=compiled with no divergence. Debounced-fn identity stays stable (timer isn't reset per render); useFlattenedSections keeps its explicit useMemo; useOnyx selector/subscription/canBeMissing behavior unchanged.
  • Generic-component splits + genericMemo deletion: old genericMemo was plain React.memo with no custom comparator, so there's no bespoke equality to lose. All 40+ importers migrated; zero references remain. Generic type params and ref forwarding preserved on the wrappers. Table middlewares (filtering/sorting) are byte-equivalent logic moves — sort() still copies before sorting (no input mutation), tie-breaking unchanged. Button's de-memoized config is safe because useKeyboardShortcut keys off primitives, not object identity.
  • List-item headers / SubStepForms / MFA: SubStepForms are driven by FormProvider+onSubmit (not refs); full {...props} spread keeps onNext/onMove/onSubmit/validate wiring intact. createScreenWithDefaults preserves {...defaultProps, ...overrideProps} order (explicit props still win), and the illustration-remount key lives downstream in OutcomeScreenBase, unaffected.

Net: safe to proceed on the code review. Since the whole class of bug this PR fixes is re-render churn that only surfaces at runtime, I'd pair the green CI with a quick manual smoke of the FlashList-heavy screens in the test plan (Distance Rates, LHN, workspace tables) after a hard refresh, plus the layout-resize check above — but I found nothing in the diff that should block it.

keyExtractor,
measuredHeightOfSuggestionRows,
}: ExternalProps<TSuggestion>) {
}: ExternalProps<unknown>) {

@situchan situchan Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NAB: Can we avoid unknown typing?

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've created a follow-up to clean this up

@MelvinBot

Copy link
Copy Markdown
Contributor

I dug adversarially into every "conditional" finding from the analysis above against the current tip (4cc9ef1), and I could not produce a single reachable regression — each hedged finding collapses to "safe" once you trace it to code. Rather than repeat vague "if OXC bails…" warnings, here's each one resolved, plus the exact flows to run if you want to catch the one class that static review genuinely can't prove.

The prior findings, resolved

#1 — Stale illustration on OutcomeScreenBase (claimed HIGH): NOT reachable. The frozen-importFn change is real, but OutcomeScreenBase is the only dynamic caller (all 20 others pass string literals), and it force-remounts on illustration change via key={props.illustration} — present in the current tip at OutcomeScreenBase.tsx:135-142 (the useMemoizedLazyAsset call lives in the inner OutcomeScreenBaseContent). It was not lost in the force-push. Every host (OutcomePage.tsx, AuthorizeTransactionPage, ChangePINAtATMPage, the FailureScreen default scenarios) either swaps in a distinct pre-built element per outcome or passes a literal illustration — nothing mutates illustration in place on a live instance. The prior comment's own repro says it won't reproduce if hosts remount per illustration; they do. Ruled out.

#2 — Split-button dropdown ref churn (claimed MED): benign, popover still anchors correctly. The anchor element is unchanged. anchorRef={dropdownAnchor} receives the exact same stable useRef object the old anchorRef={nullCheckRef(dropdownAnchor)} did — nullCheckRef was a no-op here because a ref object is never null (only its .current is). For a split button, dropdownAnchor attaches directly to the arrow <Button> at ButtonWithDropdownMenu/index.tsx:259-261 (unchanged), and the popover measures position from dropdownAnchor.current on open, not from the churning ref. The only newly-churning ref is buttonRef — a passive object ref nothing measures. No mis-anchor, no null-anchor. Benign.

#3 (useOnyx) & #4 (Table middlewares + PlatformNavigator): ruled out — not a memoization regression. These kept a generic signature, but the actual OXC whole-file bailout trigger in each was hoisted to a top-level (non-generic) function so both compilers can memoize: useOnyx's cast-in-closure → resolveSnapshotAwareResult, filtering.tsbuildInitialFilters, sorting.tsresolveActiveSorting, and the navigator body → module-scope PlatformNavigatorImpl. Decisively: the React Compiler Compliance check passed on 4cc9ef1, and that gate fails on any file where babel.memoized !== oxc.memoized. So Babel and OXC are proven to agree on all 64 changed files — the "if OXC bails while Babel doesn't" scenario these findings depend on is exactly what CI rules out.

What I'd actually verify (the deltas code review can't fully prove)

I'm not claiming these break — CI + the traces above say they don't. But the PR removes manual memoization and leans entirely on the compiler, and that only fails at runtime, so these are the precise smokes worth 5 minutes:

  1. FlashList churn regression (the exact bug class this PR fixes). Web, hard refresh, JS console open. Open Workspace → Distance Rates, Workspace → Categories/Company Cards, Search (Reports/Expenses) on a High-Traffic account. Scroll, sort a column, type in the table search, toggle filters. Pass: console stays clean, smooth scroll. Fail: "Maximum update depth exceeded" or visible re-render lag.
  2. MFA outcome illustration (guards Some initial fixes and code style updates #1). Trigger an outcome flow that shows art — e.g. Authorize-Transaction 3DS deny → outcome, or Settings → Security → 2FA. Pass: the illustration matches the outcome shown. Fail: it stays frozen on the first art shown.
  3. Navigator layout-resize (guards the getCustomState/Effects-slot refactor). Web, deep-link into a nested RHP/workspace screen, resize across the narrow↔wide breakpoint several times, then navigate back/forward. Pass: correct screen restores, no blank frames, clean console. Fail: flicker/blank frames or wrong screen on restore.

Bottom line: green CI (React Compiler Compliance, typecheck, all tests, baseline+branch perf), plus each flagged finding traced to "safe" against the current tip. I found nothing reproducible that should block this.

@jasperhuangg
jasperhuangg merged commit 5952b09 into main Jul 15, 2026
46 checks passed
@jasperhuangg
jasperhuangg deleted the rory-fix-oxc-compiler-generic-closure-bailout branch July 15, 2026 22:13
@jasperhuangg jasperhuangg changed the title Fix FlashList max-update-depth crashes from OXC React Compiler bailouts [CP staging] Fix FlashList max-update-depth crashes from OXC React Compiler bailouts Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

@OSBotify OSBotify added the CP Staging marks PRs that have been CP'd to staging label Jul 15, 2026
@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.

roryabraham pushed a commit that referenced this pull request Jul 15, 2026
…c-closure-bailout

Fix FlashList max-update-depth crashes from OXC React Compiler bailouts

(cherry picked from commit 5952b09)

(cherry-picked to staging by jasperhuangg)

Co-authored-by: Cursor <cursoragent@cursor.com>
jasperhuangg added a commit that referenced this pull request Jul 15, 2026
…454712293-1

🍒 Cherry pick PR #96168 to staging 🍒
@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Cherry-picked to staging by https://github.com/jasperhuangg in version: 9.4.34-13 🚀

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

@MelvinBot

Copy link
Copy Markdown
Contributor

Help site review — no docs changes required ✅

I reviewed all 66 changed files against the help site under App/docs/articles, and no updates are needed. I did not create a draft PR.

Why: This PR is a purely internal React Compiler (OXC) compliance refactor — splitting generic components into *Impl + typed wrappers, hoisting nested components to module scope, and swapping manual memoization for compiler auto-memoization to stop the FlashList "Maximum update depth exceeded" crashes. There are no user-facing changes: no new/renamed features, tabs, settings, or buttons; no changed flows; no copy changes. The affected screens (Distance Rates, Account, Scan expense, LHN, workspace tables) behave exactly as documented today — the fix only prevents a crash. The PR's own description confirms this ("restores memoization / compiler eligibility; no new offline-specific behavior").

The help site articles document product behavior and UI, none of which this PR alters, so there is nothing to add, remove, or correct.

If a future revision of this PR introduces any user-visible change, re-tag me and I'll draft the corresponding docs update.

@roryabraham — no help site PR was created because no documentation changes are required for this refactor. Let me know if you'd like me to reconsider any specific article.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Cherry-picked to staging by https://github.com/jasperhuangg in version: 9.4.34-14 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 cancelled 🔪
🍎 iOS 🍎 cancelled 🔪

@MelvinBot

Copy link
Copy Markdown
Contributor

No help site changes required — no draft PR created ✅

I reviewed all 66 changed files in this PR against the help site content under App/docs/articles, and no documentation updates are warranted, so I did not create a draft PR.

Why: This is a purely internal refactor to satisfy the OXC React Compiler — splitting generic components into *Impl + typed wrappers, hoisting nested components to module scope, dropping manual memoization in favor of compiler auto-memoization, and the navigator getCustomState + Effects slot pattern. It fixes the FlashList "Maximum update depth exceeded" crashes but introduces no new features, no UI changes, no new/renamed tabs, settings, or buttons, and no copy changes. The PR's own description confirms this: "restores memoization / compiler eligibility; no new offline-specific behavior."

Help site articles document product behavior and user-facing flows. Since nothing a user sees or does changes here (aside from screens no longer crashing), there is nothing in App/docs/articles to add, remove, or correct.

If any of the affected screens (e.g. Workspace → Distance Rates, LHN, workspace tables) had known-outdated documentation independent of this PR, that would be a separate docs task — but this changeset itself doesn't create a docs need.

@roryabraham — no linked help site PR was created because no docs changes are required. If you believe a specific user-facing behavior did change and should be documented, reply with the detail and I'll draft the update.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/roryabraham in version: 9.4.34-14 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 failure ❌
🍎 iOS 🍎 failure ❌

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Cherry-picked to staging by https://github.com/jasperhuangg in version: 9.4.34-14 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 failure ❌

@MelvinBot

Copy link
Copy Markdown
Contributor

Help site review — no docs changes required ✅

I reviewed all 66 changed files in this PR against App/docs/articles, and no help site updates are needed, so I did not create a docs PR.

This is a purely internal refactor to satisfy the OXC React Compiler — splitting generic components into *Impl + typed wrappers, hoisting components to module scope, dropping manual memoization in favor of compiler auto-memoization, and swapping the navigator's useCustomState/useCustomEffects for a getCustomState + Effects slot. There are no user-facing changes: no new or renamed features, tabs, settings, or buttons; no changed flows; no copy in src/languages/*. The help articles document customer-facing product behavior, and none of it is affected by this change.

If a reviewer believes any user-visible behavior does change here (e.g. a screen that now renders or navigates differently), reply with @MelvinBot pointing to the specific behavior and I'll draft the corresponding docs update.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/roryabraham in version: 9.4.34-14 🚀

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

Bundle Size Analysis (Sentry):

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Cherry-picked to staging by https://github.com/jasperhuangg in version: 9.4.35-1 🚀

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

@MelvinBot

Copy link
Copy Markdown
Contributor

No help site changes required ✅

I reviewed all 66 changed files in this PR and confirmed no updates to App/docs/articles are needed, so I did not open a docs draft PR.

Why: This is a purely internal refactor to fix FlashList "Maximum update depth exceeded" crashes caused by OXC React Compiler bailouts. Every change is in src/ code, build config (config/eslint/eslint.seatbelt.tsv), and unit tests — restructuring components into non-generic *Impl + typed wrappers, hoisting components to module scope, and restoring compiler auto-memoization. There are zero user-facing changes: no new or renamed features, tabs, settings, buttons, flows, or copy. Nothing a customer reads about in the help site changed.

The help articles under App/docs/articles document customer-facing product behavior, and this PR doesn't alter any of it, so there's nothing to add or amend there.

If you'd expected a specific feature's behavior to be user-visible here (e.g. a screen that now looks or behaves differently to end users), let me know which one and I'll re-scope the docs check against that.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/jasperhuangg in version: 9.4.35-6 🚀

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

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

Labels

CP Staging marks PRs that have been CP'd to staging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants