From ef10c7e6f50987560e584194fc7cc0b379860b54 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 01:30:42 -0700 Subject: [PATCH 01/11] fix: work around OXC React Compiler silent bailout on generic casts in 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`, `satisfies ActiveSorting`) 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 --- src/components/Table/Table.tsx | 48 ++++++++++++------- src/components/Table/middlewares/filtering.ts | 33 ++++++++----- src/components/Table/middlewares/sorting.ts | 26 +++++++++- src/hooks/useOnyx.ts | 33 +++++++++---- 4 files changed, 103 insertions(+), 37 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index f00c274742fd..f8bfdd3315d9 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -16,7 +16,7 @@ import type {FlashListRef} from '@shopify/flash-list'; import React, {useImperativeHandle, useRef} from 'react'; import type {TableContextValue} from './TableContext'; -import type {TableData, TableHandle, TableMethods, TableProps} from './types'; +import type {TableData, TableHandle, TableMethods, TableProps, TableRow} from './types'; import useFiltering from './middlewares/filtering'; import useHighlighting from './middlewares/highlight'; @@ -25,6 +25,36 @@ import useSelection from './middlewares/selection'; import useSorting from './middlewares/sorting'; import TableContext from './TableContext'; +/** + * Builds the Proxy exposed through the Table's ref, forwarding to `tableMethods` first and + * falling back to FlashList's own methods (e.g. `scrollToIndex`). + * + * This is a standalone top-level function (rather than being inlined in the `useImperativeHandle` + * callback) because OXC's React Compiler currently fails to compile a component when a generic type + * cast referencing the component's own type parameters (e.g. `as TableHandle`) + * appears inside a nested closure. That bailout is silent (no build warning) and disables automatic + * memoization for the entire file, which is what previously caused an infinite FlashList re-render. + */ +function createTableHandle( + tableMethods: TableMethods, + listRef: React.RefObject | null>, + getProcessedData: () => Array>, +): TableHandle { + return new Proxy(tableMethods, { + get: (target, property) => { + if (property in target) { + return target[property as keyof typeof target]; + } + + if (property === 'getProcessedData') { + return getProcessedData; + } + + return listRef.current?.[property as keyof FlashListRef]; + }, + }) as TableHandle; +} + /** * A composable table component that provides filtering, search, and sorting functionality. * @@ -218,21 +248,7 @@ function Table { - return new Proxy(tableMethods, { - get: (target, property) => { - if (property in target) { - return target[property as keyof typeof target]; - } - - if (property === 'getProcessedData') { - return () => processedData; - } - - return listRef.current?.[property as keyof FlashListRef]; - }, - }) as TableHandle; - }); + useImperativeHandle(ref, () => createTableHandle(tableMethods, listRef, () => processedData)); const originalDataLength = data?.length ?? 0; const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasActiveSearchString || hasActiveFilters); diff --git a/src/components/Table/middlewares/filtering.ts b/src/components/Table/middlewares/filtering.ts index 34729e841773..a9b5de66f30e 100644 --- a/src/components/Table/middlewares/filtering.ts +++ b/src/components/Table/middlewares/filtering.ts @@ -55,6 +55,27 @@ type UseFilteringResult; }; +/** + * Builds the initial filters state, defaulting every configured filter key to an empty selection. + * + * This is a standalone top-level function (rather than being inlined in the `useState` lazy initializer) + * because OXC's React Compiler currently fails to compile a component/hook when a generic type cast + * referencing the function's own type parameters (e.g. `as Record`) appears inside + * a nested closure. That bailout is silent (no build warning) and disables automatic memoization for the + * entire file. + */ +function buildInitialFilters(filters?: FilterConfig): Record { + const initialFilters = {} as Record; + + if (filters) { + for (const key of getObjectKeys(filters)) { + initialFilters[key] = []; + } + } + + return initialFilters; +} + /** * Provides functionality to filter table data. */ @@ -62,17 +83,7 @@ function useFiltering): UseFilteringResult { - const [currentFilters, setCurrentFilters] = useState>(() => { - const initialFilters = {} as Record; - - if (filters) { - for (const key of getObjectKeys(filters)) { - initialFilters[key] = []; - } - } - - return initialFilters; - }); + const [currentFilters, setCurrentFilters] = useState>(() => buildInitialFilters(filters)); const updateFilter: FilteringMethods['updateFilter'] = useCallback(({key, value}) => { setCurrentFilters((previousFilters) => ({ diff --git a/src/components/Table/middlewares/sorting.ts b/src/components/Table/middlewares/sorting.ts index a6edaa01cd81..7ce2fb989b26 100644 --- a/src/components/Table/middlewares/sorting.ts +++ b/src/components/Table/middlewares/sorting.ts @@ -77,6 +77,30 @@ type UseSortingResult = MiddlewareHookResu activeSorting: ActiveSorting; }; +/** + * Resolves the sorting configuration that should actually be applied, forcing `narrowLayoutSortColumn` + * when the table is in narrow layout. + * + * This is a standalone top-level function (rather than being inlined in the `useMemo` callback) because + * OXC's React Compiler currently fails to compile a component/hook when a generic type expression + * referencing the function's own type parameters (e.g. `satisfies ActiveSorting`) appears + * inside a nested closure. That bailout is silent (no build warning) and disables automatic memoization + * for the entire file. + * + * @template ColumnKey - The type of column keys. + */ +function resolveActiveSorting( + shouldUseNarrowTableLayout: boolean | undefined, + narrowLayoutSortColumn: ColumnKey | undefined, + userSorting: ActiveSorting, +): ActiveSorting { + if (shouldUseNarrowTableLayout && narrowLayoutSortColumn) { + return {columnKey: narrowLayoutSortColumn, order: 'asc'}; + } + + return userSorting; +} + /** * Provides functionality to sort table data. * @@ -98,7 +122,7 @@ function useSorting({ }); const activeSorting = useMemo( - () => (shouldUseNarrowTableLayout && narrowLayoutSortColumn ? ({columnKey: narrowLayoutSortColumn, order: 'asc'} satisfies ActiveSorting) : userSorting), + () => resolveActiveSorting(shouldUseNarrowTableLayout, narrowLayoutSortColumn, userSorting), [shouldUseNarrowTableLayout, narrowLayoutSortColumn, userSorting], ); diff --git a/src/hooks/useOnyx.ts b/src/hooks/useOnyx.ts index 706645a5ec9d..6d2e79f09365 100644 --- a/src/hooks/useOnyx.ts +++ b/src/hooks/useOnyx.ts @@ -47,6 +47,29 @@ const getKeyData = (snapshotData: SearchResu return getDataByPath(snapshotData?.data, key) as TReturnValue; }; +/** + * Resolves the final `useOnyx` result, extracting the specific key's data out of the search snapshot + * when applicable. + * + * This is a standalone top-level function (rather than being inlined in the `useMemo` callback) because + * OXC's React Compiler currently fails to compile a hook when a generic type cast referencing the hook's + * own type parameters (e.g. `as UseOnyxResult`) appears inside a nested closure. That + * bailout is silent (no build warning) and disables automatic memoization for the entire file. + */ +function resolveSnapshotAwareResult( + shouldUseSnapshot: boolean, + hasSelector: boolean, + originalResult: UseOnyxResult>, + key: TKey, +): UseOnyxResult { + if (!shouldUseSnapshot || hasSelector) { + return originalResult as UseOnyxResult; + } + + const keyData = getKeyData(originalResult[0] as SearchResults, key); + return [keyData, originalResult[1]] as UseOnyxResult; +} + /** * Custom hook for accessing and subscribing to Onyx data with search snapshot support */ @@ -84,15 +107,7 @@ const useOnyx: OriginalUseOnyx = => { - // if it has selector, we don't need to use snapshot here - if (!shouldUseSnapshot || selector) { - return originalResult as UseOnyxResult; - } - - const keyData = getKeyData(originalResult[0] as SearchResults, key); - return [keyData, originalResult[1]] as UseOnyxResult; - }, [shouldUseSnapshot, originalResult, key, selector]); + const result = useMemo(() => resolveSnapshotAwareResult(shouldUseSnapshot, !!selector, originalResult, key), [shouldUseSnapshot, originalResult, key, selector]); return result; }; From c86d3f7e5a188060d6abde243fb8848450f15b8e Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 03:34:15 -0700 Subject: [PATCH 02/11] fix: restore memoization dropped by SVG-memoization fix in useLazyAsset 06007ef40b6 ("Fix Account tab crash from OXC memoizing SVG elements") fixed a real bug (React Compiler under OXC was caching rendered 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 --- src/hooks/useLazyAsset.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/hooks/useLazyAsset.ts b/src/hooks/useLazyAsset.ts index 4d9ff76f77be..36d64f7da5ef 100644 --- a/src/hooks/useLazyAsset.ts +++ b/src/hooks/useLazyAsset.ts @@ -164,11 +164,13 @@ function useMemoizedLazyIllustrations; - for (const name of namesList) { - icons[name] = resolveIconComponent(assets[name as string]); - } - return icons; + return useMemo(() => { + const icons = {} as Record; + for (const name of namesList) { + icons[name] = resolveIconComponent(assets[name as string]); + } + return icons; + }, [assets, namesList]); } /** @@ -231,11 +233,13 @@ function useMemoizedLazyExpensifyIcons; - for (const name of namesList) { - icons[name] = resolveIconComponent(assets[name as string]); - } - return icons; + return useMemo(() => { + const icons = {} as Record; + for (const name of namesList) { + icons[name] = resolveIconComponent(assets[name as string]); + } + return icons; + }, [assets, namesList]); } export {useMemoizedLazyAsset, useMemoizedLazyIllustrations, useMemoizedLazyExpensifyIcons}; From e44cf0ad8cfa7ef20288cde3ebfbf727d4616099 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 09:56:56 -0700 Subject: [PATCH 03/11] fix: remove manual memoization now that OXC and Babel compile these files 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 --- src/components/Table/Table.tsx | 3 +- src/components/Table/middlewares/filtering.ts | 21 ++++++-------- src/components/Table/middlewares/sorting.ts | 28 ++++++++----------- src/hooks/useOnyx.ts | 16 ++++------- 4 files changed, 26 insertions(+), 42 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index f8bfdd3315d9..9bdaa9b8130c 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -7,7 +7,6 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import genericMemo from '@libs/genericMemo'; import CONST from '@src/CONST'; @@ -306,4 +305,4 @@ function Table): UseFilteringResult { const [currentFilters, setCurrentFilters] = useState>(() => buildInitialFilters(filters)); - const updateFilter: FilteringMethods['updateFilter'] = useCallback(({key, value}) => { + const updateFilter: FilteringMethods['updateFilter'] = ({key, value}) => { setCurrentFilters((previousFilters) => ({ ...previousFilters, [key]: value, })); - }, []); + }; - const getActiveFilters: FilteringMethods['getActiveFilters'] = useCallback(() => currentFilters, [currentFilters]); + const getActiveFilters: FilteringMethods['getActiveFilters'] = () => currentFilters; - const middleware: Middleware = useCallback((data) => filter({data, filters, currentFilters, isItemInFilter}), [filters, currentFilters, isItemInFilter]); + const middleware: Middleware = (data) => filter({data, filters, currentFilters, isItemInFilter}); - const methods: FilteringMethods = useMemo( - () => ({ - updateFilter, - getActiveFilters, - }), - [updateFilter, getActiveFilters], - ); + const methods: FilteringMethods = { + updateFilter, + getActiveFilters, + }; const hasActiveFilters = getObjectValues(currentFilters).some((filterValues) => filterValues.length > 0); diff --git a/src/components/Table/middlewares/sorting.ts b/src/components/Table/middlewares/sorting.ts index 7ce2fb989b26..94ebb89025f7 100644 --- a/src/components/Table/middlewares/sorting.ts +++ b/src/components/Table/middlewares/sorting.ts @@ -1,6 +1,6 @@ import type {SetStateAction} from 'react'; -import {useCallback, useMemo, useState} from 'react'; +import {useState} from 'react'; import type {Middleware, MiddlewareHookResult} from './types'; @@ -121,12 +121,9 @@ function useSorting({ order: 'asc', }); - const activeSorting = useMemo( - () => resolveActiveSorting(shouldUseNarrowTableLayout, narrowLayoutSortColumn, userSorting), - [shouldUseNarrowTableLayout, narrowLayoutSortColumn, userSorting], - ); + const activeSorting = resolveActiveSorting(shouldUseNarrowTableLayout, narrowLayoutSortColumn, userSorting); - const toggleColumnSorting: SortingMethods['toggleColumnSorting'] = useCallback((columnKey) => { + const toggleColumnSorting: SortingMethods['toggleColumnSorting'] = (columnKey) => { setUserSorting((previousSorting) => { const columnKeyToUse = columnKey ?? previousSorting.columnKey; const orderToUse = previousSorting.order === 'asc' ? 'desc' : 'asc'; @@ -136,20 +133,17 @@ function useSorting({ order: orderToUse, }; }); - }, []); + }; - const getActiveSorting: SortingMethods['getActiveSorting'] = useCallback(() => activeSorting, [activeSorting]); + const getActiveSorting: SortingMethods['getActiveSorting'] = () => activeSorting; - const middleware: Middleware = useCallback((data) => sort({data, activeSorting, compareItems}), [activeSorting, compareItems]); + const middleware: Middleware = (data) => sort({data, activeSorting, compareItems}); - const methods: SortingMethods = useMemo( - () => ({ - updateSorting: setUserSorting, - toggleColumnSorting, - getActiveSorting, - }), - [toggleColumnSorting, getActiveSorting], - ); + const methods: SortingMethods = { + updateSorting: setUserSorting, + toggleColumnSorting, + getActiveSorting, + }; return {middleware, activeSorting, methods}; } diff --git a/src/hooks/useOnyx.ts b/src/hooks/useOnyx.ts index 6d2e79f09365..ea04ec1a7d38 100644 --- a/src/hooks/useOnyx.ts +++ b/src/hooks/useOnyx.ts @@ -8,7 +8,7 @@ import type {SearchResults} from '@src/types/onyx'; import type {DependencyList} from 'react'; import type {OnyxCollection, OnyxEntry, OnyxKey, OnyxValue, UseOnyxOptions, UseOnyxResult} from 'react-native-onyx'; -import {use, useMemo} from 'react'; +import {use} from 'react'; // eslint-disable-next-line no-restricted-imports import {useOnyx as originalUseOnyx} from 'react-native-onyx'; @@ -74,7 +74,7 @@ function resolveSnapshotAwareResult( * Custom hook for accessing and subscribing to Onyx data with search snapshot support */ const useOnyx: OriginalUseOnyx = >(key: TKey, options?: UseOnyxOptions, dependencies?: DependencyList) => { - const isSnapshotCompatibleKey = useMemo(() => !key.startsWith(ONYXKEYS.COLLECTION.SNAPSHOT) && CONST.SEARCH.SNAPSHOT_ONYX_KEYS.some((snapshotKey) => key.startsWith(snapshotKey)), [key]); + const isSnapshotCompatibleKey = !key.startsWith(ONYXKEYS.COLLECTION.SNAPSHOT) && CONST.SEARCH.SNAPSHOT_ONYX_KEYS.some((snapshotKey) => key.startsWith(snapshotKey)); const isOnSearch = useIsOnSearch(); let currentSearchHash: number | undefined; @@ -93,21 +93,15 @@ const useOnyx: OriginalUseOnyx = { - if (!selectorProp || !shouldUseSnapshot) { - return selectorProp; - } - - return (data: OnyxValue | undefined) => selectorProp(getKeyData(data as SearchResults, key)); - }, [selectorProp, shouldUseSnapshot, key]); + const selector = !selectorProp || !shouldUseSnapshot ? selectorProp : (data: OnyxValue | undefined) => selectorProp(getKeyData(data as SearchResults, key)); const onyxOptions: UseOnyxOptions> = {...optionsWithoutSelector, selector}; const snapshotKey = shouldUseSnapshot ? (`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}` as OnyxKey) : key; const originalResult = originalUseOnyx(snapshotKey, onyxOptions, dependencies); - // Extract and memoize the specific key data from snapshot if in search mode - const result = useMemo(() => resolveSnapshotAwareResult(shouldUseSnapshot, !!selector, originalResult, key), [shouldUseSnapshot, originalResult, key, selector]); + // Extract the specific key data from snapshot if in search mode + const result = resolveSnapshotAwareResult(shouldUseSnapshot, !!selector, originalResult, key); return result; }; From dd7da8b94acdae5ba4cadfae8bbe50af663f47c5 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 11:49:54 -0700 Subject: [PATCH 04/11] fix: make lazy asset hooks compile under OXC React Compiler 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 --- src/hooks/useLazyAsset.ts | 69 +++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/src/hooks/useLazyAsset.ts b/src/hooks/useLazyAsset.ts index 36d64f7da5ef..440a7efeab96 100644 --- a/src/hooks/useLazyAsset.ts +++ b/src/hooks/useLazyAsset.ts @@ -6,7 +6,7 @@ import PlaceholderIcon from '@components/Icon/PlaceholderIcon'; import type IconAsset from '@src/types/utils/IconAsset'; -import {isValidElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {isValidElement, useEffect, useMemo, useRef, useState} from 'react'; function resolveIconComponent(asset: IconAsset | undefined, fallback: IconAsset = PlaceholderIcon): IconAsset { if (asset == null || isValidElement(asset)) { @@ -95,7 +95,10 @@ function useLazyAsset(importFn: () => {default: T} | Promise<{default: T}>, f * Supports both synchronous and async return values for optimal performance */ function useMemoizedLazyAsset(importFn: () => {default: T} | Promise<{default: T}>, fallback?: T): {asset: T} { - const stableImportFn = useCallback(() => importFn(), [importFn]); + // Capture the first importFn only. Callers pass inline loaders that close over constant asset + // names; re-binding every render would invalidate useLazyAsset's effect and loop on setState. + // useState's initializer runs once, which avoids writing a ref during render (OXC bailout). + const [stableImportFn] = useState(() => importFn); const {asset, isLoaded} = useLazyAsset(stableImportFn, fallback); return { @@ -104,23 +107,20 @@ function useMemoizedLazyAsset(importFn: () => {default: T} } /** - * Hook for loading multiple illustrations at once - * Loads the illustrations chunk once and returns an object keyed by illustration names - * Uses synchronous access when chunk is cached to avoid flash - * @param names - Array of illustration names - * @returns Object with illustration names as keys and IconAsset as values + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on `const TName` / `TName[number]` type params inside hooks ("Unsupported declaration type for hoisting"). */ -function useMemoizedLazyIllustrations(names: TName): Record { +function useMemoizedLazyIllustrationsImpl(names: readonly IllustrationName[]): Record { const cachedChunk = getIllustrationsChunk(); const namesKey = useMemo(() => names.join(','), [names]); - const namesList = useMemo(() => namesKey.split(',') as Array, [namesKey]); + const namesList = useMemo(() => namesKey.split(',') as IllustrationName[], [namesKey]); // Try to get cached chunk synchronously to avoid Promise microtask delay const [assets, setAssets] = useState>(() => { if (cachedChunk) { const loaded: Record = {}; for (const name of names) { - loaded[name as string] = cachedChunk.getIllustration(name) ?? PlaceholderIcon; + loaded[name] = cachedChunk.getIllustration(name) ?? PlaceholderIcon; } return loaded; } @@ -143,7 +143,7 @@ function useMemoizedLazyIllustrations = {}; for (const name of namesList) { - loaded[name as string] = chunk.getIllustration(name) ?? PlaceholderIcon; + loaded[name] = chunk.getIllustration(name) ?? PlaceholderIcon; } setAssets(loaded); }) @@ -154,7 +154,7 @@ function useMemoizedLazyIllustrations = {}; for (const name of namesList) { - fallback[name as string] = PlaceholderIcon; + fallback[name] = PlaceholderIcon; } setAssets(fallback); }); @@ -165,32 +165,40 @@ function useMemoizedLazyIllustrations { - const icons = {} as Record; + const icons: Record = {}; for (const name of namesList) { - icons[name] = resolveIconComponent(assets[name as string]); + icons[name] = resolveIconComponent(assets[name]); } return icons; }, [assets, namesList]); } /** - * Hook for loading multiple Expensify icons at once - * Loads the Expensify icons chunk once and returns an object keyed by icon names + * Hook for loading multiple illustrations at once + * Loads the illustrations chunk once and returns an object keyed by illustration names * Uses synchronous access when chunk is cached to avoid flash - * @param names - Array of Expensify icon names - * @returns Object with icon names as keys and IconAsset as values + * @param names - Array of illustration names + * @returns Object with illustration names as keys and IconAsset as values */ -function useMemoizedLazyExpensifyIcons(names: TName): Record { +function useMemoizedLazyIllustrations(names: TName): Record { + return useMemoizedLazyIllustrationsImpl(names) as Record; +} + +/** + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on `const TName` / `TName[number]` type params inside hooks ("Unsupported declaration type for hoisting"). + */ +function useMemoizedLazyExpensifyIconsImpl(names: readonly ExpensifyIconName[]): Record { const cachedChunk = getExpensifyIconsChunk(); const namesKey = useMemo(() => names.join(','), [names]); - const namesList = useMemo(() => namesKey.split(',') as Array, [namesKey]); + const namesList = useMemo(() => namesKey.split(',') as ExpensifyIconName[], [namesKey]); // Try to get cached chunk synchronously to avoid Promise microtask delay const [assets, setAssets] = useState>(() => { if (cachedChunk) { const loaded: Record = {}; for (const name of namesList) { - loaded[name as string] = cachedChunk.getExpensifyIcon(name) ?? PlaceholderIcon; + loaded[name] = cachedChunk.getExpensifyIcon(name) ?? PlaceholderIcon; } return loaded; } @@ -212,7 +220,7 @@ function useMemoizedLazyExpensifyIcons = {}; for (const name of namesList) { - loaded[name as string] = chunk.getExpensifyIcon(name) ?? PlaceholderIcon; + loaded[name] = chunk.getExpensifyIcon(name) ?? PlaceholderIcon; } setAssets(loaded); }) @@ -223,7 +231,7 @@ function useMemoizedLazyExpensifyIcons = {}; for (const name of namesList) { - fallback[name as string] = PlaceholderIcon; + fallback[name] = PlaceholderIcon; } setAssets(fallback); }); @@ -234,13 +242,24 @@ function useMemoizedLazyExpensifyIcons { - const icons = {} as Record; + const icons: Record = {}; for (const name of namesList) { - icons[name] = resolveIconComponent(assets[name as string]); + icons[name] = resolveIconComponent(assets[name]); } return icons; }, [assets, namesList]); } +/** + * Hook for loading multiple Expensify icons at once + * Loads the Expensify icons chunk once and returns an object keyed by icon names + * Uses synchronous access when chunk is cached to avoid flash + * @param names - Array of Expensify icon names + * @returns Object with icon names as keys and IconAsset as values + */ +function useMemoizedLazyExpensifyIcons(names: TName): Record { + return useMemoizedLazyExpensifyIconsImpl(names) as Record; +} + export {useMemoizedLazyAsset, useMemoizedLazyIllustrations, useMemoizedLazyExpensifyIcons}; export default useLazyAsset; From ad5738c2f4a79a345ab361483b9dc724487bc9e9 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 12:22:10 -0700 Subject: [PATCH 05/11] fix: work around OXC generic hoisting bailouts 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 --- .../BaseAutoCompleteSuggestions.tsx | 19 ++- .../FlatList/hooks/useFlatListScrollKey.ts | 111 ++++++------- src/components/Picker/BasePicker.tsx | 17 +- src/components/Picker/index.tsx | 20 ++- src/components/Rule/TextBase.tsx | 41 +++-- .../Search/FilterComponents/SingleSelect.tsx | 18 +- .../ListItem/BaseListItemHeader.tsx | 18 +- .../ListItem/CardListItemHeader.tsx | 17 +- .../ListItem/MemberListItemHeader.tsx | 17 +- .../ListItem/TransactionGroupListExpanded.tsx | 23 ++- .../ListItem/TransactionGroupListItem.tsx | 19 ++- .../ListItem/WithdrawalIDListItemHeader.tsx | 17 +- .../SelectionList/BaseSelectionList.tsx | 32 ++-- .../BaseSelectionListWithSections.tsx | 30 ++-- .../hooks/useFlattenedSections.ts | 46 +++--- src/components/SubStepForms/AddressStep.tsx | 19 ++- .../SubStepForms/DateOfBirthStep.tsx | 21 ++- .../SubStepForms/DocusignFullStep.tsx | 27 ++- src/components/SubStepForms/FullNameStep.tsx | 23 ++- .../SubStepForms/PushRowFieldsStep.tsx | 30 +++- .../SubStepForms/RegistrationNumberStep.tsx | 30 ++-- .../WorkspaceCategoryRulesTable/index.tsx | 21 ++- .../EditableCell/usePopoverEditState.ts | 96 ++++++----- src/hooks/useDebounce.ts | 42 +++-- src/hooks/useDebounceNonReactive.ts | 52 +++--- src/hooks/useInitialSelection.ts | 38 +++-- src/hooks/useStableIndexedHandler.ts | 40 ++--- src/hooks/useStepFormSubmit.ts | 57 ++++--- src/hooks/useSubPage/index.tsx | 139 ++++++++-------- src/hooks/useWorkletStateMachine/index.ts | 156 +++++++++--------- .../inbox/report/useDebouncedSaveDraft.ts | 60 ++++--- 31 files changed, 737 insertions(+), 559 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 7a99b7508ba1..142fefc881bb 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -5,7 +5,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {hasHoverSupport} from '@libs/DeviceCapabilities'; -import genericMemo from '@libs/genericMemo'; import CONST from '@src/CONST'; @@ -20,7 +19,11 @@ import type {RenderSuggestionMenuItemProps} from './types'; type ExternalProps = Omit, 'left' | 'bottom'>; -function BaseAutoCompleteSuggestions({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function BaseAutoCompleteSuggestionsImpl({ highlightedSuggestionIndex = 0, onSelect, accessibilityLabelExtractor, @@ -28,18 +31,18 @@ function BaseAutoCompleteSuggestions({ suggestions, keyExtractor, measuredHeightOfSuggestionRows, -}: ExternalProps) { +}: ExternalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const rowHeight = useSharedValue(0); const prevRowHeightRef = useRef(measuredHeightOfSuggestionRows); const fadeInOpacity = useSharedValue(0); - const scrollRef = useRef>(null); + const scrollRef = useRef>(null); /** * Render a suggestion menu item component. */ const renderItem = useCallback( - ({item, index}: RenderSuggestionMenuItemProps): ReactElement => ( + ({item, index}: RenderSuggestionMenuItemProps): ReactElement => ( StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)} hoverDimmingValue={1} @@ -122,4 +125,8 @@ function BaseAutoCompleteSuggestions({ ); } -export default genericMemo(BaseAutoCompleteSuggestions); +function BaseAutoCompleteSuggestions(props: ExternalProps) { + return )} />; +} + +export default BaseAutoCompleteSuggestions; diff --git a/src/components/FlatList/hooks/useFlatListScrollKey.ts b/src/components/FlatList/hooks/useFlatListScrollKey.ts index 9643251f4031..3900225a7bdf 100644 --- a/src/components/FlatList/hooks/useFlatListScrollKey.ts +++ b/src/components/FlatList/hooks/useFlatListScrollKey.ts @@ -10,29 +10,34 @@ import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; import type {ForwardedRef} from 'react'; +import type {RefObject} from 'react'; import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {createElement, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import useFlatListHandle from './useFlatListHandle'; -type FlatListScrollKeyProps = { - ref?: ForwardedRef>; - data: T[]; - keyExtractor: (item: T, index: number) => string; +type FlatListScrollKeyProps = { + ref?: ForwardedRef>; + data: unknown[]; + keyExtractor: (item: unknown, index: number) => string; initialScrollKey: string | null | undefined; inverted: boolean; onStartReached?: ((info: {distanceFromStart: number}) => void) | null; shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; + renderItem: ListRenderItem; remainingItemsToDisplay?: number; onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; }; const AUTOSCROLL_TO_TOP_THRESHOLD = 250; -export default function useFlatListScrollKey({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). + */ +function useFlatListScrollKeyImpl({ data, keyExtractor, initialScrollKey, @@ -43,51 +48,36 @@ export default function useFlatListScrollKey({ ref, remainingItemsToDisplay, onScrollToIndexFailed, -}: FlatListScrollKeyProps) { - // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. - // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more - // previous items, until everything is rendered. We also progressively render new data that is added at the start of the - // list to make sure `maintainVisibleContentPosition` works as expected. +}: FlatListScrollKeyProps) { const [currentDataId, setCurrentDataId] = useState(() => { if (initialScrollKey) { return initialScrollKey; } return null; }); - const currentDataIndex = useMemo(() => (currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); + const currentDataIndex = currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId); const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); const [isQueueRendering, setIsQueueRendering] = useState(false); - // On the web platform, when data.length === 1, `maintainVisibleContentPosition` does not work. - // Therefore, we need to duplicate the data to ensure data.length >= 2 - const shouldDuplicateData = useMemo(() => !inverted && data.length === 1 && isInitialData && getPlatform() === CONST.PLATFORM.WEB, [data.length, inverted, isInitialData]); + const shouldDuplicateData = !inverted && data.length === 1 && isInitialData && getPlatform() === CONST.PLATFORM.WEB; - const displayedData = useMemo(() => { + const displayedData = (() => { if (shouldDuplicateData) { - return [{...data.at(0), reportActionID: '0'} as T, ...data]; + return [{...(data.at(0) as Record), reportActionID: '0'}, ...data]; } if (currentDataIndex <= 0) { return data; } - // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. - // So we will need to return at least the last 2 elements in this case. const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; - // We always render the list from the highlighted item to the end of the list because: - // - With an inverted FlatList, items are rendered from bottom to top, - // so the highlighted item stays at the bottom and within the visible viewport. - // - With a non-inverted (base) FlatList, items are rendered from top to bottom, - // making the highlighted item appear at the top of the list. - // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place - // as the rest of the items are appended. return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); - }, [currentDataIndex, data, inverted, isInitialData, shouldDuplicateData]); + })(); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); const dataIndexDifference = data.length - displayedData.length; - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); + useEffect(() => { return () => { renderQueue.cancel(); @@ -103,16 +93,11 @@ export default function useFlatListScrollKey({ setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : null); }); - const handleStartReached = useCallback( - (info: {distanceFromStart: number}) => { - renderQueue.add(info); - }, - [renderQueue], - ); + const handleStartReached = (info: {distanceFromStart: number}) => { + renderQueue.add(info); + }; useEffect(() => { - // In cases where the data is empty on the initial render, `handleStartReached` will never be triggered. - // We'll manually invoke it in this scenario. if (inverted || data.length > 0) { return; } @@ -121,13 +106,12 @@ export default function useFlatListScrollKey({ }, []); const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); - const maintainVisibleContentPosition = useMemo(() => { + const maintainVisibleContentPosition = (() => { if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { return undefined; } const config: ScrollViewProps['maintainVisibleContentPosition'] = { - // This needs to be 1 to avoid using loading views as anchors. minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, }; @@ -136,38 +120,28 @@ export default function useFlatListScrollKey({ } return config; - }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + })(); - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - if (shouldDuplicateData && index === 1) { - return React.createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); - } + const handleRenderItem = ({item, index, separators}: ListRenderItemInfo) => { + if (shouldDuplicateData && index === 1) { + return createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); + } - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [shouldDuplicateData, renderItem, dataIndexDifference], - ); + return renderItem({item, index: index + dataIndexDifference, separators}); + }; useEffect(() => { if (inverted || isInitialData || isQueueRendering) { return; } - // Unlike an inverted FlatList, a non-inverted FlatList can have data.length === 0, - // which causes the initial value of `minIndexForVisible` to be 0. - // When data.length increases and `minIndexForVisible` updates accordingly, - // it can lead to a crash due to inconsistent rendering behavior. - // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift - // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. requestAnimationFrame(() => { setShouldPreserveVisibleContentPosition(false); }); }, [inverted, isInitialData, isQueueRendering]); - const listRef = useRef | null>(null); - useFlatListHandle({ + const listRef = useRef | null>(null); + useFlatListHandle({ ref, listRef, remainingItemsToDisplay, @@ -186,4 +160,25 @@ export default function useFlatListScrollKey({ }; } +type FlatListScrollKeyPropsGeneric = { + ref?: ForwardedRef>; + data: T[]; + keyExtractor: (item: T, index: number) => string; + initialScrollKey: string | null | undefined; + inverted: boolean; + onStartReached?: ((info: {distanceFromStart: number}) => void) | null; + shouldEnableAutoScrollToTopThreshold?: boolean; + renderItem: ListRenderItem; + remainingItemsToDisplay?: number; + onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; +}; + +export default function useFlatListScrollKey(props: FlatListScrollKeyPropsGeneric) { + return useFlatListScrollKeyImpl(props as FlatListScrollKeyProps) as ReturnType & { + displayedData: T[]; + handleRenderItem: ListRenderItem; + listRef: RefObject | null>; + }; +} + export {AUTOSCROLL_TO_TOP_THRESHOLD}; diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index 0a3cd4aaea12..423fc9fe938b 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -9,7 +9,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobile} from '@libs/Browser'; -import genericMemo from '@libs/genericMemo'; import CONST from '@src/CONST'; @@ -28,7 +27,11 @@ import getAccessibilityLabelConfig from './getAccessibilityLabelConfig'; type IconToRender = () => ReactElement; -function BasePicker({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function BasePickerImpl({ items, backgroundColor, inputID, @@ -49,7 +52,7 @@ function BasePicker({ onBlur = () => {}, additionalPickerEvents = () => {}, ref, -}: BasePickerProps) { +}: BasePickerProps) { const icons = useMemoizedLazyExpensifyIcons(['DownArrow']); const {translate} = useLocalize(); const theme = useTheme(); @@ -84,7 +87,7 @@ function BasePicker({ * Forms use inputID to set values. But BasePicker passes an index as the second parameter to onValueChange * We are overriding this behavior to make BasePicker work with Form */ - const onValueChange = (inputValue: TPickerValue, index: number) => { + const onValueChange = (inputValue: unknown, index: number) => { if (inputID) { onInputChange?.(inputValue); return; @@ -263,4 +266,8 @@ function BasePicker({ ); } -export default genericMemo(BasePicker); +function BasePicker(props: BasePickerProps) { + return )} />; +} + +export default BasePicker; diff --git a/src/components/Picker/index.tsx b/src/components/Picker/index.tsx index 71a47f4e4fa9..5bebd2bc79f2 100644 --- a/src/components/Picker/index.tsx +++ b/src/components/Picker/index.tsx @@ -1,13 +1,15 @@ -import genericMemo from '@libs/genericMemo'; - import React from 'react'; import type {AdditionalPickerEvents, BasePickerProps, OnChange, OnMouseDown} from './types'; import BasePicker from './BasePicker'; -function Picker({ref, ...props}: BasePickerProps) { - const additionalPickerEvents = (onMouseDown: OnMouseDown, onChange: OnChange): AdditionalPickerEvents => ({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function PickerImpl({ref, ...props}: BasePickerProps) { + const additionalPickerEvents = (onMouseDown: OnMouseDown, onChange: OnChange): AdditionalPickerEvents => ({ onMouseDown, onChange: (e) => { if (e.target.selectedIndex === undefined) { @@ -15,12 +17,12 @@ function Picker({ref, ...props}: BasePickerProps) { } const index = e.target.selectedIndex; const value = e.target.options[index].value; - onChange(value as TPickerValue, index); + onChange(value, index); }, }); return ( - + ({ref, ...props}: BasePickerProps) { ); } -export default genericMemo(Picker); +function Picker({ref, ...props}: BasePickerProps) { + return )} />; +} + +export default Picker; diff --git a/src/components/Rule/TextBase.tsx b/src/components/Rule/TextBase.tsx index 6a2cc2cea5d1..8f005be627c6 100644 --- a/src/components/Rule/TextBase.tsx +++ b/src/components/Rule/TextBase.tsx @@ -8,7 +8,6 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import {isInvalidMerchantValue, isRequiredFulfilled, isValidInputLength} from '@libs/ValidationUtils'; import variables from '@styles/variables'; @@ -35,17 +34,23 @@ type TextBaseProps = { isMarkdownEnabled?: boolean; }; -function TextBase({ - fieldID, - hint, - isRequired, - title, - label, - onSubmit, - formID, - characterLimit = CONST.MERCHANT_NAME_MAX_BYTES, - isMarkdownEnabled = false, -}: TextBaseProps) { +type TextBasePropsWidened = { + fieldID: string; + hint?: string; + isRequired?: boolean; + title: string; + label: string; + characterLimit?: number; + formID: OnyxFormKey; + onSubmit: (values: FormOnyxValues) => void; + isMarkdownEnabled?: boolean; +}; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function TextBaseImpl({fieldID, hint, isRequired, title, label, onSubmit, formID, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES, isMarkdownEnabled = false}: TextBasePropsWidened) { const {translate} = useLocalize(); const [form] = useOnyx(formID); const styles = useThemeStyles(); @@ -53,9 +58,9 @@ function TextBase({ const currentValue = (form as Record)?.[fieldID] ?? ''; const {inputCallbackRef} = useAutoFocusInput(); - const validate = (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const fieldValue = values[fieldID as keyof FormOnyxValues] ?? ''; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const fieldValue = (values as Record)[fieldID] ?? ''; if (typeof fieldValue !== 'string') { return errors; @@ -111,4 +116,8 @@ function TextBase({ ); } -export default genericMemo(TextBase); +function TextBase(props: TextBaseProps) { + return ; +} + +export default TextBase; diff --git a/src/components/Search/FilterComponents/SingleSelect.tsx b/src/components/Search/FilterComponents/SingleSelect.tsx index 1cda6e106e11..41f0611750e5 100644 --- a/src/components/Search/FilterComponents/SingleSelect.tsx +++ b/src/components/Search/FilterComponents/SingleSelect.tsx @@ -7,8 +7,6 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; - import variables from '@styles/variables'; import React, {Activity, useState} from 'react'; @@ -42,7 +40,11 @@ type SingleSelectProps = SearchFilterCommonProps | undefi hasHeader?: boolean; }; -function SingleSelect({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function SingleSelectImpl({ value, items, isSearchable, @@ -56,7 +58,7 @@ function SingleSelect({ footer, allowDeselect, onChange, -}: SingleSelectProps) { +}: SingleSelectProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [selectedItem, setSelectedItem] = useState(value); @@ -95,7 +97,7 @@ function SingleSelect({ }; })(); - const updateSelectedItem = (item: ListItem) => { + const updateSelectedItem = (item: ListItem) => { const newItem = items.find((i) => i.value === item.keyForList); if (!newItem) { return; @@ -150,5 +152,9 @@ function SingleSelect({ ); } +function SingleSelect(props: SingleSelectProps) { + return )} />; +} + export type {SingleSelectItem}; -export default genericMemo(SingleSelect); +export default SingleSelect; diff --git a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx index 968886bc9ffb..b11cf330ece4 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -8,8 +8,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; - import CONST from '@src/CONST'; import React from 'react'; @@ -91,7 +89,11 @@ type BaseListItemHeaderProps = { columns?: SearchColumnType[]; }; -function BaseListItemHeader({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function BaseListItemHeaderImpl({ item, displayName, groupColumnKey, @@ -104,7 +106,7 @@ function BaseListItemHeader({ isExpanded, onDownArrowClick, columns, -}: BaseListItemHeaderProps) { +}: BaseListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -151,7 +153,7 @@ function BaseListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(item as unknown as TItem)} + onPress={() => onCheckboxPress?.(item as ListItem)} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || item.isDisabledCheckbox} @@ -191,5 +193,9 @@ function BaseListItemHeader({ ); } -export default genericMemo(BaseListItemHeader); +function BaseListItemHeader(props: BaseListItemHeaderProps) { + return )} />; +} + +export default BaseListItemHeader; export type {BaseListItemHeaderProps}; diff --git a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx index 15311bfa6a4f..2d7470ef6d0d 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -11,7 +11,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import CONST from '@src/CONST'; @@ -58,7 +57,11 @@ type CardListItemHeaderProps = { columns?: SearchColumnType[]; }; -function CardListItemHeader({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function CardListItemHeaderImpl({ card: cardItem, onCheckboxPress, isDisabled, @@ -69,7 +72,7 @@ function CardListItemHeader({ onDownArrowClick, columns, isExpanded, -}: CardListItemHeaderProps) { +}: CardListItemHeaderProps) { const theme = useTheme(); const styles = useThemeStyles(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -151,7 +154,7 @@ function CardListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(cardItem as unknown as TItem)} + onPress={() => onCheckboxPress?.(cardItem as ListItem)} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || cardItem.isDisabledCheckbox} @@ -201,4 +204,8 @@ function CardListItemHeader({ ); } -export default genericMemo(CardListItemHeader); +function CardListItemHeader(props: CardListItemHeaderProps) { + return )} />; +} + +export default CardListItemHeader; diff --git a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx index 728e2f4c51f2..2c02b305cfa0 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -9,7 +9,6 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import CONST from '@src/CONST'; @@ -55,7 +54,11 @@ type MemberListItemHeaderProps = { isLargeScreenWidth?: boolean; }; -function MemberListItemHeader({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function MemberListItemHeaderImpl({ member: memberItem, onCheckboxPress, isDisabled, @@ -66,7 +69,7 @@ function MemberListItemHeader({ onDownArrowClick, columns, isLargeScreenWidth, -}: MemberListItemHeaderProps) { +}: MemberListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate, formatPhoneNumber} = useLocalize(); @@ -136,7 +139,7 @@ function MemberListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(memberItem as unknown as TItem)} + onPress={() => onCheckboxPress?.(memberItem as ListItem)} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || memberItem.isDisabledCheckbox} @@ -189,4 +192,8 @@ function MemberListItemHeader({ ); } -export default genericMemo(MemberListItemHeader); +function MemberListItemHeader(props: MemberListItemHeaderProps) { + return )} />; +} + +export default MemberListItemHeader; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 83031b683d25..47746cb3dc2e 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -19,7 +19,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import genericMemo from '@libs/genericMemo'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getReportIDForTransaction} from '@libs/MoneyRequestReportUtils'; import openInternalRouteInNewTab, {isModifiedMousePress} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; @@ -48,7 +47,11 @@ import {View} from 'react-native'; import type {TransactionGroupListExpandedProps, TransactionListItemType} from './types'; -function TransactionGroupListExpanded({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function TransactionGroupListExpandedImpl({ transactionsQueryJSON, showTooltip, canSelectMultiple, @@ -72,7 +75,7 @@ function TransactionGroupListExpanded({ nonPersonalAndWorkspaceCards, onUndelete, hideSearchTableHeader, -}: TransactionGroupListExpandedProps) { +}: TransactionGroupListExpandedProps) { const theme = useTheme(); const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); @@ -167,7 +170,7 @@ function TransactionGroupListExpanded({ const isActionColumnWide = transactions.some((transaction) => !!transaction.isActionColumnWide || isDeletedTransaction(transaction)); const {markReportRHPWidth} = useWideRHPActions(); - const selectRow = onSelectRow as (item: TItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; + const selectRow = onSelectRow as (item: ListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; const getTransactionPreviewData = (transactionItem: TransactionListItemType): TransactionPreviewData => { const parentReportAction = getReportAction(transactionItem?.reportID, transactionItem?.reportAction?.reportActionID); const parentReport = getReportOrDraftReport(transactionItem?.reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem?.reportID}`]); @@ -235,7 +238,7 @@ function TransactionGroupListExpanded({ // The arrow navigation in RHP is only allowed for group-by:reports if (!isExpenseReportType) { - selectRow(transactionItem as unknown as TItem, getTransactionPreviewData(transactionItem), event); + selectRow(transactionItem as ListItem, getTransactionPreviewData(transactionItem), event); return; } @@ -283,7 +286,7 @@ function TransactionGroupListExpanded({ const handleOnPress = (transaction: TransactionListItemType, event?: ModifiedMouseEvent) => { // A deleted transaction has no report to open, so a row press toggles its selection instead of dead-ending in navigation. if (isMobileSelectionModeEnabled || isDeletedTransaction(transaction) || isTransactionPendingDelete(transaction)) { - onSelectionButtonPress?.(transaction as unknown as TItem); + onSelectionButtonPress?.(transaction as ListItem); return; } openReportInRHP(transaction, event); @@ -366,7 +369,7 @@ function TransactionGroupListExpanded({ shouldUseNarrowLayout={!isLargeScreenWidth} shouldShowCheckbox={!!canSelectMultiple} checkboxSentryLabel={CONST.SENTRY_LABEL.SEARCH.EXPANDED_TRANSACTION_ROW_CHECKBOX} - onCheckboxPress={() => onSelectionButtonPress?.(transaction as unknown as TItem)} + onCheckboxPress={() => onSelectionButtonPress?.(transaction as ListItem)} columns={currentColumns} onButtonPress={(event) => handleButtonPress(transaction, event)} style={[styles.noBorderRadius, isLargeScreenWidth ? [styles.p3, styles.pv2, styles.tableRowHeight] : styles.p4, styles.flex1]} @@ -426,4 +429,8 @@ function TransactionGroupListExpanded({ ); } -export default genericMemo(TransactionGroupListExpanded); +function TransactionGroupListExpanded(props: TransactionGroupListExpandedProps) { + return )} />; +} + +export default TransactionGroupListExpanded; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 2ae26d74a261..7d0306f6ed3e 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -22,7 +22,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {search} from '@libs/actions/Search'; import type {TransactionPreviewData} from '@libs/actions/Search'; -import genericMemo from '@libs/genericMemo'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; @@ -75,7 +74,11 @@ import WeekListItemHeader from './WeekListItemHeader'; import WithdrawalIDListItemHeader from './WithdrawalIDListItemHeader'; import YearListItemHeader from './YearListItemHeader'; -function TransactionGroupListItem({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function TransactionGroupListItemImpl({ item, isFocused, showTooltip, @@ -98,7 +101,7 @@ function TransactionGroupListItem({ userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, onUndelete, -}: TransactionGroupListItemProps) { +}: TransactionGroupListItemProps) { const groupItem = item as unknown as TransactionGroupListItemType; const theme = useTheme(); @@ -316,10 +319,10 @@ function TransactionGroupListItem({ }; const onExpandedRowLongPress = (transaction: TransactionListItemType) => { - onLongPressRow?.(transaction as unknown as TItem); + onLongPressRow?.(transaction as ListItem); }; - const handleSelectionButtonPress = (val: TItem) => { + const handleSelectionButtonPress = (val: ListItem) => { onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions); }; @@ -625,4 +628,8 @@ function TransactionGroupListItem({ ); } -export default genericMemo(TransactionGroupListItem); +function TransactionGroupListItem(props: TransactionGroupListItemProps) { + return )} />; +} + +export default TransactionGroupListItem; diff --git a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx index b07addd333f5..7327f9aa8b29 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -16,7 +16,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; -import genericMemo from '@libs/genericMemo'; import {getSettlementStatus, getSettlementStatusBadgeProps} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; @@ -62,7 +61,11 @@ type WithdrawalIDListItemHeaderProps = { columns?: SearchColumnType[]; }; -function WithdrawalIDListItemHeader({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function WithdrawalIDListItemHeaderImpl({ withdrawalID: withdrawalIDItem, onCheckboxPress, isDisabled, @@ -72,7 +75,7 @@ function WithdrawalIDListItemHeader({ onDownArrowClick, isExpanded, columns, -}: WithdrawalIDListItemHeaderProps) { +}: WithdrawalIDListItemHeaderProps) { const {isLargeScreenWidth} = useResponsiveLayout(); const theme = useTheme(); const styles = useThemeStyles(); @@ -193,7 +196,7 @@ function WithdrawalIDListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(withdrawalIDItem as unknown as TItem)} + onPress={() => onCheckboxPress?.(withdrawalIDItem as ListItem)} isChecked={isSelectAllChecked} disabled={!!isDisabled || withdrawalIDItem.isDisabledCheckbox} accessibilityLabel={translate('common.select')} @@ -258,4 +261,8 @@ function WithdrawalIDListItemHeader({ ); } -export default genericMemo(WithdrawalIDListItemHeader); +function WithdrawalIDListItemHeader(props: WithdrawalIDListItemHeaderProps) { + return )} />; +} + +export default WithdrawalIDListItemHeader; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 6d279b9f647a..cb3c4a26153c 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -4,8 +4,6 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; - import CONST from '@src/CONST'; import getEmptyArray from '@src/types/utils/getEmptyArray'; @@ -40,7 +38,11 @@ const ANIMATED_HIGHLIGHT_DURATION = CONST.ANIMATED_HIGHLIGHT_END_DELAY + CONST.ANIMATED_HIGHLIGHT_END_DURATION; -function BaseSelectionList({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function BaseSelectionListImpl({ data, ref, ListItem, @@ -99,7 +101,7 @@ function BaseSelectionList({ shouldDisableHoverStyle = false, selectionButtonPosition, setShouldDisableHoverStyle = () => {}, -}: SelectionListProps) { +}: SelectionListProps) { const styles = useThemeStyles(); const isFocused = useIsFocused(); const scrollEnabled = useScrollEnabled(); @@ -110,7 +112,7 @@ function BaseSelectionList({ // Kept out of the destructuring default so the `!!` doesn't bail the component out of React Compiler. const shouldShowTextInput = shouldShowTextInputProp ?? !!textInputOptions?.label; - const listRef = useRef | null>(null); + const listRef = useRef | null>(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, data); const itemFocusTimeoutRef = useRef(null); const keyboardListenerRef = useRef | null>(null); @@ -119,7 +121,7 @@ function BaseSelectionList({ const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); const isItemSelected = useCallback( - (item: TItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple), + (item: ListItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple), [isSelected, selectedItems, canSelectMultiple], ); @@ -127,9 +129,9 @@ function BaseSelectionList({ const hasFooter = !!footerContent || confirmButtonOptions?.showButton; - const dataDetails = useMemo>(() => { + const dataDetails = useMemo>(() => { const {disabledIndexes, disabledArrowKeyIndexes, selectedOptions, disabledSelectedIndexes} = data.reduce( - (acc: {disabledIndexes: number[]; disabledArrowKeyIndexes: number[]; selectedOptions: TItem[]; disabledSelectedIndexes: number[]}, item: TItem, index: number) => { + (acc: {disabledIndexes: number[]; disabledArrowKeyIndexes: number[]; selectedOptions: ListItem[]; disabledSelectedIndexes: number[]}, item: ListItem, index: number) => { const idx = item.index ?? index; const itemIsSelected = isItemSelected(item); const isItemDisabled = isDisabled || (!!item?.isDisabled && !itemIsSelected); @@ -189,7 +191,7 @@ function BaseSelectionList({ const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; const selectRow = useCallback( - (item: TItem, indexToFocus?: number) => { + (item: ListItem, indexToFocus?: number) => { if (!isFocused) { return; } @@ -280,7 +282,7 @@ function BaseSelectionList({ ); }; - const renderItem: ListRenderItem = ({item, index}: ListRenderItemInfo) => { + const renderItem: ListRenderItem = ({item, index}: ListRenderItemInfo) => { const selected = isItemSelected(item); const isItemDisabled = isDisabled || (!!item.isDisabled && !selected); const isItemFocused = (!isDisabled || selected) && focusedIndex === index; @@ -347,7 +349,7 @@ function BaseSelectionList({ // It ensures the entire list item is visible, not just the input field. // Added specifically for SplitExpensePage const scrollToFocusedInput = useCallback( - (item: TItem) => { + (item: ListItem) => { if (!listRef.current) { return; } @@ -533,7 +535,7 @@ function BaseSelectionList({ )} - + footerContent={footerContent} confirmButtonOptions={confirmButtonOptions} addBottomSafeAreaPadding={addBottomSafeAreaPadding} @@ -542,4 +544,8 @@ function BaseSelectionList({ ); } -export default genericMemo(BaseSelectionList); +function BaseSelectionList(props: SelectionListProps) { + return )} />; +} + +export default BaseSelectionList; diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index cd7b5afd43ed..29cdca29e0da 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -20,8 +20,6 @@ import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; - import CONST from '@src/CONST'; import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; @@ -34,11 +32,15 @@ import {View} from 'react-native'; import type {FlattenedItem, ListItem, SelectionListWithSectionsProps} from './types'; -function getItemType(item: FlattenedItem): ValueOf { +function getItemType(item: FlattenedItem): ValueOf { return item?.type ?? CONST.SECTION_LIST_ITEM_TYPE.ROW; } -function BaseSelectionListWithSections({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function BaseSelectionListWithSectionsImpl({ sections, ref, ListItem, @@ -85,7 +87,7 @@ function BaseSelectionListWithSections({ shouldDisableHoverStyle, selectionButtonPosition, setShouldDisableHoverStyle = () => {}, -}: SelectionListWithSectionsProps) { +}: SelectionListWithSectionsProps) { const styles = useThemeStyles(); const isScreenFocused = useIsFocused(); const scrollEnabled = useScrollEnabled(); @@ -96,7 +98,7 @@ function BaseSelectionListWithSections({ const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle; const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); - const listRef = useRef> | null>(null); + const listRef = useRef> | null>(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, flattenedData); const {containerRef, trackScrollOffset, scrollInputIntoView} = useScrollToFocusedInput(listRef, isKeyboardShown); @@ -116,7 +118,7 @@ function BaseSelectionListWithSections({ const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed); - const getFocusedItem = useCallback((): TItem | undefined => { + const getFocusedItem = useCallback((): ListItem | undefined => { if (focusedIndex < 0 || focusedIndex >= flattenedData.length) { return; } @@ -124,10 +126,10 @@ function BaseSelectionListWithSections({ if (!item || shouldTreatItemAsDisabled(item)) { return; } - return item as TItem; + return item as ListItem; }, [flattenedData, focusedIndex]); - const selectRow = (item: TItem, indexToFocus?: number) => { + const selectRow = (item: ListItem, indexToFocus?: number) => { if (!isScreenFocused) { return; } @@ -252,7 +254,7 @@ function BaseSelectionListWithSections({ ); }; - const renderItem = ({item, index}: ListRenderItemInfo>) => { + const renderItem = ({item, index}: ListRenderItemInfo>) => { if (!item) { return null; } @@ -355,7 +357,7 @@ function BaseSelectionListWithSections({ /> )} {!!footerContent && ( - + footerContent={footerContent} addBottomSafeAreaPadding={addBottomSafeAreaPadding} /> @@ -364,4 +366,8 @@ function BaseSelectionListWithSections({ ); } -export default genericMemo(BaseSelectionListWithSections); +function BaseSelectionListWithSections(props: SelectionListWithSectionsProps) { + return )} />; +} + +export default BaseSelectionListWithSections; diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 906e956a1f7a..100b81b746cd 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -18,34 +18,23 @@ function shouldTreatItemAsDisabled(item: TItem | Flatten return !!item?.isDisabled && !isItemSelected(item as TItem); } -type UseFlattenedSectionsResult = { - /** Flattened array of headers and items for FlashList */ - flattenedData: Array>; - - /** Indices of disabled items (headers + disabled items) for arrow key navigation */ +type UseFlattenedSectionsResult = { + flattenedData: Array>; disabledIndexes: number[]; - - /** Total count of row items (excluding headers) */ itemsCount: number; - - /** Array of selected items */ - selectedItems: TItem[]; - - /** Index of initially focused item in flattenedData, or -1 if none */ + selectedItems: ListItem[]; initialFocusedIndex: number; - - /** Index of the first focusable (non-header) item in flattenedData. Returns 0 if no items exist. */ firstFocusableIndex: number; }; /** - * Hook that flattens sections with headers and items into a single array for FlashList. - * Also computes disabled indexes, selected items, and initial focus index. + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -function useFlattenedSections(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult { +function useFlattenedSectionsImpl(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult { return useMemo(() => { - const data: Array> = []; - const selectedOptions: TItem[] = []; + const data: Array> = []; + const selectedOptions: ListItem[] = []; const disabledIndices: number[] = []; let focusedIndex = -1; let firstNonHeaderIndex = -1; @@ -74,7 +63,7 @@ function useFlattenedSections(sections: Array; + } as SectionListItem; data.push(itemData); if (firstNonHeaderIndex === -1) { @@ -107,5 +96,22 @@ function useFlattenedSections(sections: Array = { + flattenedData: Array>; + disabledIndexes: number[]; + itemsCount: number; + selectedItems: TItem[]; + initialFocusedIndex: number; + firstFocusableIndex: number; +}; + +/** + * Hook that flattens sections with headers and items into a single array for FlashList. + * Also computes disabled indexes, selected items, and initial focus index. + */ +function useFlattenedSections(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResultGeneric { + return useFlattenedSectionsImpl(sections as Array>, initiallyFocusedItemKey) as UseFlattenedSectionsResultGeneric; +} + export default useFlattenedSections; export {isItemSelected, shouldTreatItemAsDisabled}; diff --git a/src/components/SubStepForms/AddressStep.tsx b/src/components/SubStepForms/AddressStep.tsx index 7ef762848003..e4d27e8d1afd 100644 --- a/src/components/SubStepForms/AddressStep.tsx +++ b/src/components/SubStepForms/AddressStep.tsx @@ -8,7 +8,6 @@ import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import genericMemo from '@libs/genericMemo'; import {getCountryZipRegexDetails, getFieldRequiredErrors, getInvalidAddressErrorTranslationPath, isValidZipCode, isValidZipCodeForCountry} from '@libs/ValidationUtils'; import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields'; @@ -108,7 +107,13 @@ type AddressStepProps = SubStepProp shouldShowPatriotActLink?: boolean; }; -function AddressStep({ +type AddressStepPropsWidened = Omit, never>; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function AddressStepImpl({ formID, formTitle, formPOBoxDisclaimer, @@ -130,7 +135,7 @@ function AddressStep({ shouldValidateZipCodeFormat = true, shouldShowPatriotActLink = false, forwardedFSClass, -}: AddressStepProps) { +}: AddressStepPropsWidened) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -151,7 +156,7 @@ function AddressStep({ }, [defaultValues.country, formID, inputFieldsIDs.country, shouldAllowCountryChange]); const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { + (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, stepFields, translate); const street = getStringFormValue(values, inputFieldsIDs.street); @@ -217,4 +222,8 @@ function AddressStep({ ); } -export default genericMemo(AddressStep); +function AddressStep(props: AddressStepProps) { + return ; +} + +export default AddressStep; diff --git a/src/components/SubStepForms/DateOfBirthStep.tsx b/src/components/SubStepForms/DateOfBirthStep.tsx index 28c6128523c2..563dab80d77c 100644 --- a/src/components/SubStepForms/DateOfBirthStep.tsx +++ b/src/components/SubStepForms/DateOfBirthStep.tsx @@ -10,7 +10,6 @@ import type {SubPageProps} from '@hooks/useSubPage/types'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import genericMemo from '@libs/genericMemo'; import {getFieldRequiredErrors, isValidPastDate, meetsMaximumAgeRequirement, meetsMinimumAgeRequirement} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; @@ -49,7 +48,13 @@ type DateOfBirthStepProps = SubPage shouldShowPatriotActLink?: boolean; }; -function DateOfBirthStep({ +type DateOfBirthStepPropsWidened = Omit, never>; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function DateOfBirthStepImpl({ formID, formTitle, customValidate, @@ -61,7 +66,7 @@ function DateOfBirthStep({ footerComponent, shouldShowPatriotActLink = false, forwardedFSClass, -}: DateOfBirthStepProps) { +}: DateOfBirthStepPropsWidened) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -69,10 +74,10 @@ function DateOfBirthStep({ const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { + (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, stepFields, translate); - const valuesToValidate = values[dobInputID as keyof FormOnyxValues] as string; + const valuesToValidate = (values as Record)[dobInputID] as string; if (valuesToValidate) { if (!isValidPastDate(valuesToValidate) || !meetsMaximumAgeRequirement(valuesToValidate)) { // @ts-expect-error type mismatch to be fixed @@ -119,4 +124,8 @@ function DateOfBirthStep({ ); } -export default genericMemo(DateOfBirthStep); +function DateOfBirthStep(props: DateOfBirthStepProps) { + return ; +} + +export default DateOfBirthStep; diff --git a/src/components/SubStepForms/DocusignFullStep.tsx b/src/components/SubStepForms/DocusignFullStep.tsx index 7715a9def722..12d15524b815 100644 --- a/src/components/SubStepForms/DocusignFullStep.tsx +++ b/src/components/SubStepForms/DocusignFullStep.tsx @@ -10,7 +10,6 @@ import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import mapCurrencyToCountry from '@libs/mapCurrencyToCountry'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -52,17 +51,13 @@ type DocusignFullStepProps = { startStepIndex: number; }; -function DocusignFullStep({ - defaultValue, - formID, - inputID, - isLoading, - onBackButtonPress, - onSubmit, - currency, - startStepIndex, - stepNames, -}: DocusignFullStepProps) { +type DocusignFullStepPropsWidened = Omit, never>; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function DocusignFullStepImpl({defaultValue, formID, inputID, isLoading, onBackButtonPress, onSubmit, currency, startStepIndex, stepNames}: DocusignFullStepPropsWidened) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); @@ -72,7 +67,7 @@ function DocusignFullStep({ const [uploadedFiles, setUploadedFiles] = useState(defaultValue); const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { + (values: FormOnyxValues): FormInputErrors => { return getFieldRequiredErrors(values, [inputID], translate); }, [inputID, translate], @@ -173,4 +168,8 @@ function DocusignFullStep({ ); } -export default genericMemo(DocusignFullStep); +function DocusignFullStep(props: DocusignFullStepProps) { + return ; +} + +export default DocusignFullStep; diff --git a/src/components/SubStepForms/FullNameStep.tsx b/src/components/SubStepForms/FullNameStep.tsx index 5c1a3295518f..a390df9b11c1 100644 --- a/src/components/SubStepForms/FullNameStep.tsx +++ b/src/components/SubStepForms/FullNameStep.tsx @@ -10,7 +10,6 @@ import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import genericMemo from '@libs/genericMemo'; import {doesContainReservedWord, getFieldRequiredErrors, isRequiredFulfilled, isValidLegalName} from '@libs/ValidationUtils'; import HelpLinks from '@pages/ReimbursementAccount/USD/Requestor/PersonalInfo/HelpLinks'; @@ -69,7 +68,13 @@ type FullNameStepProps = SubStepPro enabledWhenOffline?: boolean; }; -function FullNameStep({ +type FullNameStepPropsWidened = Omit, never>; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function FullNameStepImpl({ formID, formTitle, formSubtitle, @@ -86,15 +91,15 @@ function FullNameStep({ shouldShowPatriotActLink = false, forwardedFSClass, enabledWhenOffline: enabledWhenOfflineProp = true, -}: FullNameStepProps) { +}: FullNameStepPropsWidened) { const {translate} = useLocalize(); const styles = useThemeStyles(); const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { + (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, stepFields, translate); - const firstName = values[firstNameInputID as keyof FormOnyxValues] as string; + const firstName = (values as Record)[firstNameInputID] as string; if (!isRequiredFulfilled(firstName)) { // @ts-expect-error type mismatch to be fixed errors[firstNameInputID] = translate('common.error.fieldRequired'); @@ -111,7 +116,7 @@ function FullNameStep({ errors[firstNameInputID] = translate('personalDetails.error.containsReservedWord'); } - const lastName = values[lastNameInputID as keyof FormOnyxValues] as string; + const lastName = (values as Record)[lastNameInputID] as string; if (!isRequiredFulfilled(lastName)) { // @ts-expect-error type mismatch to be fixed errors[lastNameInputID] = translate('common.error.fieldRequired'); @@ -179,4 +184,8 @@ function FullNameStep({ ); } -export default genericMemo(FullNameStep); +function FullNameStep(props: FullNameStepProps) { + return ; +} + +export default FullNameStep; diff --git a/src/components/SubStepForms/PushRowFieldsStep.tsx b/src/components/SubStepForms/PushRowFieldsStep.tsx index 3eaaa6fd8c96..0dd4531ebf20 100644 --- a/src/components/SubStepForms/PushRowFieldsStep.tsx +++ b/src/components/SubStepForms/PushRowFieldsStep.tsx @@ -8,7 +8,6 @@ import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; @@ -37,13 +36,30 @@ type PushRowFieldsStepProps = SubSt pushRowFields: Array>; }; -function PushRowFieldsStep({formID, formTitle, pushRowFields, onSubmit, isEditing}: PushRowFieldsStepProps) { +type PushRowFieldWidened = { + inputID: FormOnyxKeys; + defaultValue: string; + options: Record; + description: string; + modalHeaderTitle: string; + searchInputTitle: string; +}; + +type PushRowFieldsStepPropsWidened = Omit, 'pushRowFields'> & { + pushRowFields: Array; +}; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function PushRowFieldsStepImpl({formID, formTitle, pushRowFields, onSubmit, isEditing}: PushRowFieldsStepPropsWidened) { const {translate} = useLocalize(); const styles = useThemeStyles(); const pushRowFieldsIDs = pushRowFields.map((field) => field.inputID); const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { + (values: FormOnyxValues): FormInputErrors => { return getFieldRequiredErrors(values, pushRowFieldsIDs, translate); }, [pushRowFieldsIDs, translate], @@ -59,7 +75,7 @@ function PushRowFieldsStep({formID, validate={validate} > {formTitle} - {pushRowFields.map((pushRowField: PushRowField) => ( + {pushRowFields.map((pushRowField: PushRowFieldWidened) => ( ({formID, ); } -export default genericMemo(PushRowFieldsStep); +function PushRowFieldsStep(props: PushRowFieldsStepProps) { + return ; +} + +export default PushRowFieldsStep; diff --git a/src/components/SubStepForms/RegistrationNumberStep.tsx b/src/components/SubStepForms/RegistrationNumberStep.tsx index 092f13a957e0..83bca9547cd2 100644 --- a/src/components/SubStepForms/RegistrationNumberStep.tsx +++ b/src/components/SubStepForms/RegistrationNumberStep.tsx @@ -14,7 +14,6 @@ import type {SubStepProps} from '@hooks/useSubStep/types'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import {getFieldRequiredErrors, isValidRegistrationNumber} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; @@ -44,15 +43,13 @@ type RegistrationNumberStepProps = shouldDelayAutoFocus?: boolean; }; -function RegistrationNumberStep({ - formID, - onSubmit, - inputID, - defaultValue, - isEditing, - country, - shouldDelayAutoFocus = false, -}: RegistrationNumberStepProps) { +type RegistrationNumberStepPropsWidened = Omit, never>; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function RegistrationNumberStepImpl({formID, onSubmit, inputID, defaultValue, isEditing, country, shouldDelayAutoFocus = false}: RegistrationNumberStepPropsWidened) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -65,11 +62,12 @@ function RegistrationNumberStep({ }, [country]); const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { + (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, [inputID], translate); - if (values[inputID] && !isValidRegistrationNumber(values[inputID] as string, country)) { - errors[inputID] = translate('businessInfoStep.error.registrationNumber'); + const registrationNumber = (values as Record)[inputID as string] as string; + if (registrationNumber && !isValidRegistrationNumber(registrationNumber, country)) { + (errors as Record)[inputID as string] = translate('businessInfoStep.error.registrationNumber'); } return errors; @@ -119,4 +117,8 @@ function RegistrationNumberStep({ ); } -export default genericMemo(RegistrationNumberStep); +function RegistrationNumberStep(props: RegistrationNumberStepProps) { + return ; +} + +export default RegistrationNumberStep; diff --git a/src/components/Tables/WorkspaceCategoryRulesTable/index.tsx b/src/components/Tables/WorkspaceCategoryRulesTable/index.tsx index 39f2c1d6168f..6c53f9170b16 100644 --- a/src/components/Tables/WorkspaceCategoryRulesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoryRulesTable/index.tsx @@ -6,7 +6,6 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; @@ -40,7 +39,11 @@ type WorkspaceCategoryRulesTableProps = { renderRow: (props: TableRenderRowProps) => React.ReactElement; }; -function WorkspaceCategoryRulesTable({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function WorkspaceCategoryRulesTableImpl({ rulesData, selectionEnabled, selectedKeys, @@ -52,7 +55,7 @@ function WorkspaceCategoryRulesTable({ ruleColumnLabel, emptyState, renderRow, -}: WorkspaceCategoryRulesTableProps) { +}: WorkspaceCategoryRulesTableProps) { const {localeCompare} = useLocalize(); const styles = useThemeStyles(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -71,7 +74,7 @@ function WorkspaceCategoryRulesTable({ {key: 'actions', label: '', sortable: false, width: variables.tableCaretColumnWidth}, ]; - const compareItems: CompareItemsCallback = (a, b, activeSorting) => { + const compareItems: CompareItemsCallback = (a, b, activeSorting) => { const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; if (activeSorting.columnKey === 'type') { @@ -89,12 +92,12 @@ function WorkspaceCategoryRulesTable({ return 0; }; - const isItemInSearch: IsItemInSearchCallback = (item, searchString) => { + const isItemInSearch: IsItemInSearchCallback = (item, searchString) => { const matchingItems = tokenizedSearch([item], searchString, (i) => i.searchTokens); return matchingItems.length > 0; }; - const renderItem = ({item, index}: ListRenderItemInfo) => renderRow({item, rowIndex: index, shouldUseNarrowTableLayout}); + const renderItem = ({item, index}: ListRenderItemInfo) => renderRow({item, rowIndex: index, shouldUseNarrowTableLayout}); return ( ({ ); } -export default genericMemo(WorkspaceCategoryRulesTable); +function WorkspaceCategoryRulesTable(props: WorkspaceCategoryRulesTableProps) { + return )} />; +} + +export default WorkspaceCategoryRulesTable; diff --git a/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts b/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts index 01ebf0a8dd9e..4be1e571a4b3 100644 --- a/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts +++ b/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts @@ -5,25 +5,25 @@ import CONST from '@src/CONST'; import type {View} from 'react-native'; import type {ValueOf} from 'type-fest'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; type PopoverPosition = { horizontal: number; vertical: number; }; -type UsePopoverEditStateOptions = { +type UsePopoverEditStateOptions = { /** Whether editing is currently permitted. When false, editing will be cancelled. */ canEdit: boolean | undefined; /** The current value being edited */ - value?: T; + value?: unknown; /** Callback when the value is saved */ - onSave?: (value: T) => void; + onSave?: (value: unknown) => void; /** Custom equality function. If not provided, Object.is is used. */ - isEqual?: (newValue: T, originalValue: T) => boolean; + isEqual?: (newValue: unknown, originalValue: unknown) => boolean; /** Height of the popover content (used for overflow detection). Defaults to CONST.POPOVER_DATE_MAX_HEIGHT */ popoverHeight?: number; @@ -38,19 +38,10 @@ type UsePopoverEditStateOptions = { }; /** - * Hook for managing popover-based editing state (date picker, category picker, etc.). - * - * Handles: - * - Anchor ref for popover positioning - * - measureInWindow-based position calculation - * - Overflow detection (inverts when too close to bottom) - * - Adaptive height calculation (shrinks popover when space is limited) - * - Auto-open after layout via InteractionManager - * - isEditing + isPopoverVisible toggling - * - Auto-cancel when canEdit becomes false - * - Value comparison to prevent no-op saves + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -function usePopoverEditState({ +function usePopoverEditStateImpl({ canEdit, value, onSave, @@ -58,7 +49,7 @@ function usePopoverEditState({ popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, padding = CONST.MODAL.POPOVER_MENU_PADDING, anchorEdge = CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, -}: UsePopoverEditStateOptions) { +}: UsePopoverEditStateOptions) { const {windowHeight} = useWindowDimensions(); const anchorRef = useRef(null); const [isEditing, setIsEditing] = useState(false); @@ -66,7 +57,7 @@ function usePopoverEditState({ const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0}); const [isInverted, setIsInverted] = useState(false); - const openPopover = useCallback(() => { + const openPopover = () => { anchorRef.current?.measureInWindow((x, y, width, height) => { const wouldExceedBottom = y + popoverHeight + padding > windowHeight; setIsInverted(wouldExceedBottom); @@ -76,40 +67,30 @@ function usePopoverEditState({ }); setIsPopoverVisible(true); }); - }, [anchorEdge, padding, popoverHeight, windowHeight]); + }; - const startEditing = useCallback(() => { + const startEditing = () => { setIsEditing(true); - // EditableCell renders conditionally based on isEditing, defer measurement until that render completes and the anchor is laid out requestAnimationFrame(() => { openPopover(); }); - }, [openPopover]); + }; - const cancelEditing = useCallback(() => { + const cancelEditing = () => { setIsPopoverVisible(false); setIsEditing(false); - }, []); + }; - /** - * Handles saving a new value. - * Compares the new value with the original value and only calls onSave if they differ. - * Always closes the popover after handling. - */ - const handleSave = useCallback( - (newValue: T) => { - if (value !== undefined && onSave) { - const shouldSave = isEqual ? !isEqual(newValue, value) : !Object.is(newValue, value); - if (shouldSave) { - onSave(newValue); - } + const handleSave = (newValue: unknown) => { + if (value !== undefined && onSave) { + const shouldSave = isEqual ? !isEqual(newValue, value) : !Object.is(newValue, value); + if (shouldSave) { + onSave(newValue); } - cancelEditing(); - }, - [value, onSave, isEqual, cancelEditing], - ); + } + cancelEditing(); + }; - // Cancel editing when permission is revoked (e.g., transaction status changed) useEffect(() => { if (canEdit || !isEditing) { return; @@ -117,7 +98,7 @@ function usePopoverEditState({ queueMicrotask(() => { cancelEditing(); }); - }, [canEdit, isEditing, cancelEditing]); + }, [canEdit, isEditing]); return { isEditing, @@ -131,4 +112,33 @@ function usePopoverEditState({ }; } +type UsePopoverEditStateOptionsGeneric = { + canEdit: boolean | undefined; + value?: T; + onSave?: (value: T) => void; + isEqual?: (newValue: T, originalValue: T) => boolean; + popoverHeight?: number; + padding?: number; + anchorEdge?: ValueOf; +}; + +/** + * Hook for managing popover-based editing state (date picker, category picker, etc.). + * + * Handles: + * - Anchor ref for popover positioning + * - measureInWindow-based position calculation + * - Overflow detection (inverts when too close to bottom) + * - Adaptive height calculation (shrinks popover when space is limited) + * - Auto-open after layout via InteractionManager + * - isEditing + isPopoverVisible toggling + * - Auto-cancel when canEdit becomes false + * - Value comparison to prevent no-op saves + */ +function usePopoverEditState(options: UsePopoverEditStateOptionsGeneric) { + return usePopoverEditStateImpl(options as UsePopoverEditStateOptions) as ReturnType & { + handleSave: (newValue: T) => void; + }; +} + export default usePopoverEditState; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index 113f0081940a..2915b14c691f 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -2,27 +2,17 @@ import type {DebouncedFunc, DebounceSettings} from 'lodash'; import lodashDebounce from 'lodash/debounce'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericFunction = (...args: any[]) => void; /** - * Create and return a debounced function. - * - * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). - * This is especially important in the case of func. To prevent that, pass stable references. - * - * @param func The function to debounce. - * @param wait The number of milliseconds to delay. - * @param options The options object. - * @param options.leading Specify invoking on the leading edge of the timeout. - * @param options.maxWait The maximum time func is allowed to be delayed before it’s invoked. - * @param options.trailing Specify invoking on the trailing edge of the timeout. - * @returns Returns a function to call the debounced function. + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -export default function useDebounce(func: T, wait: number, options?: DebounceSettings): T { - const debouncedFnRef = useRef | undefined>(undefined); +function useDebounceImpl(func: GenericFunction, wait: number, options?: DebounceSettings): GenericFunction { + const debouncedFnRef = useRef | undefined>(undefined); const {leading, maxWait, trailing = true} = options ?? {}; useEffect(() => { @@ -35,13 +25,29 @@ export default function useDebounce(func: T, wait: nu }; }, [func, wait, leading, maxWait, trailing]); - const debounceCallback = useCallback((...args: Parameters) => { + return (...args: unknown[]) => { const debouncedFn = debouncedFnRef.current; if (debouncedFn) { debouncedFn(...args); } - }, []); + }; +} - return debounceCallback as T; +/** + * Create and return a debounced function. + * + * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). + * This is especially important in the case of func. To prevent that, pass stable references. + * + * @param func The function to debounce. + * @param wait The number of milliseconds to delay. + * @param options The options object. + * @param options.leading Specify invoking on the leading edge of the timeout. + * @param options.maxWait The maximum time func is allowed to be delayed before it's invoked. + * @param options.trailing Specify invoking on the trailing edge of the timeout. + * @returns Returns a function to call the debounced function. + */ +export default function useDebounce(func: T, wait: number, options?: DebounceSettings): T { + return useDebounceImpl(func, wait, options) as T; } diff --git a/src/hooks/useDebounceNonReactive.ts b/src/hooks/useDebounceNonReactive.ts index d6b27baa7e70..25ec02d1e9bf 100644 --- a/src/hooks/useDebounceNonReactive.ts +++ b/src/hooks/useDebounceNonReactive.ts @@ -2,41 +2,28 @@ import type {DebouncedFunc, DebounceSettings} from 'lodash'; import lodashDebounce from 'lodash/debounce'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericFunction = (...args: any[]) => void; /** - * Create and return a debounced function. - * - * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). - * This hook doesn't react on function identity changes and will not cancel the debounce in case of function identity change. - * This is important because we want to debounce the function call and not the function reference. - * - * @param func The function to debounce. - * @param wait The number of milliseconds to delay. - * @param options The options object. - * @param options.leading Specify invoking on the leading edge of the timeout. - * @param options.maxWait The maximum time func is allowed to be delayed before it’s invoked. - * @param options.trailing Specify invoking on the trailing edge of the timeout. - * @returns Returns a function to call the debounced function. + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -export default function useDebounceNonReactive(func: T, wait: number, options?: DebounceSettings): T { - const funcRef = useRef(func); // Store the latest func reference - const debouncedFnRef = useRef | undefined>(undefined); +function useDebounceNonReactiveImpl(func: GenericFunction, wait: number, options?: DebounceSettings): GenericFunction { + const funcRef = useRef(func); + const debouncedFnRef = useRef | undefined>(undefined); const {leading, maxWait, trailing = true} = options ?? {}; useEffect(() => { - // Update the funcRef dynamically to avoid recreating debounce funcRef.current = func; }, [func]); - // Recreate the debounce instance only if debounce settings change useEffect(() => { const debouncedFn = lodashDebounce( - (...args: Parameters) => { - funcRef.current(...args); // Use the latest func reference + (...args: unknown[]) => { + funcRef.current(...args); }, wait, {leading, maxWait, trailing}, @@ -49,9 +36,26 @@ export default function useDebounceNonReactive(func: }; }, [wait, leading, maxWait, trailing]); - const debounceCallback = useCallback((...args: Parameters) => { + return (...args: unknown[]) => { debouncedFnRef.current?.(...args); - }, []); + }; +} - return debounceCallback as T; +/** + * Create and return a debounced function. + * + * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). + * This hook doesn't react on function identity changes and will not cancel the debounce in case of function identity change. + * This is important because we want to debounce the function call and not the function reference. + * + * @param func The function to debounce. + * @param wait The number of milliseconds to delay. + * @param options The options object. + * @param options.leading Specify invoking on the leading edge of the timeout. + * @param options.maxWait The maximum time func is allowed to be delayed before it's invoked. + * @param options.trailing Specify invoking on the trailing edge of the timeout. + * @returns Returns a function to call the debounced function. + */ +export default function useDebounceNonReactive(func: T, wait: number, options?: DebounceSettings): T { + return useDebounceNonReactiveImpl(func, wait, options) as T; } diff --git a/src/hooks/useInitialSelection.ts b/src/hooks/useInitialSelection.ts index d3f85dd36b16..36c0b2422834 100644 --- a/src/hooks/useInitialSelection.ts +++ b/src/hooks/useInitialSelection.ts @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; type UseInitialSelectionOptions = { /** Whether the current cycle is visible; refresh the snapshot when it becomes visible */ @@ -10,18 +10,18 @@ type UseInitialSelectionOptions = { }; /** - * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. - * Callers can refresh the snapshot when a modal becomes visible or via screen focus. + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { +function useInitialSelectionImpl(selection: unknown, options: UseInitialSelectionOptions = {}) { const {isVisible, resetOnFocus = false} = options; const [initialSelection, setInitialSelection] = useState(selection); const latestSelectionRef = useRef(selection); const previousIsVisibleRef = useRef(isVisible); - const updateInitialSelection = useCallback((nextSelection: T) => { - setInitialSelection((previousSelection) => (Object.is(previousSelection, nextSelection) ? previousSelection : nextSelection)); - }, []); + const updateInitialSelection = (nextSelection: unknown) => { + setInitialSelection((previousSelection: unknown) => (Object.is(previousSelection, nextSelection) ? previousSelection : nextSelection)); + }; useEffect(() => { latestSelectionRef.current = selection; @@ -38,19 +38,25 @@ function useInitialSelection(selection: T, options: UseInitialSelectionOption // Refresh only when a new visible cycle starts. // Live selection changes while the picker stays open should not repin or refocus the list. updateInitialSelection(latestSelectionRef.current); - }, [isVisible, updateInitialSelection]); + }, [isVisible]); - useFocusEffect( - useCallback(() => { - if (!resetOnFocus) { - return; - } + useFocusEffect(() => { + if (!resetOnFocus) { + return; + } - updateInitialSelection(latestSelectionRef.current); - }, [resetOnFocus, updateInitialSelection]), - ); + updateInitialSelection(latestSelectionRef.current); + }); return initialSelection; } +/** + * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. + * Callers can refresh the snapshot when a modal becomes visible or via screen focus. + */ +function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { + return useInitialSelectionImpl(selection, options) as T; +} + export default useInitialSelection; diff --git a/src/hooks/useStableIndexedHandler.ts b/src/hooks/useStableIndexedHandler.ts index 3cc57208f374..9fdbc60eceb5 100644 --- a/src/hooks/useStableIndexedHandler.ts +++ b/src/hooks/useStableIndexedHandler.ts @@ -1,4 +1,23 @@ -import {useCallback, useRef} from 'react'; +import {useRef} from 'react'; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). + */ +function useStableIndexedHandlerImpl(handler: (index: number, ...args: unknown[]) => void): (index: number) => (...args: unknown[]) => void { + const cacheRef = useRef void>>(new Map()); + + return (index: number) => { + const cache = cacheRef.current; + const cached = cache.get(index); + if (cached) { + return cached; + } + const bound = (...args: unknown[]) => handler(index, ...args); + cache.set(index, bound); + return bound; + }; +} /** * Returns a factory that, given an index, returns a referentially-stable @@ -16,24 +35,7 @@ import {useCallback, useRef} from 'react'; * factory will be invalidated each render. */ function useStableIndexedHandler(handler: (index: number, ...args: Args) => void): (index: number) => (...args: Args) => void { - const cacheRef = useRef void>>(new Map()); - - // Memoize the factory so its identity is stable across renders (and only changes with `handler`), - // matching the documented contract. OXC's React Compiler does not memoize this hook on web, so - // without this useCallback the factory would be a fresh reference every render there. - return useCallback( - (index: number) => { - const cache = cacheRef.current; - const cached = cache.get(index); - if (cached) { - return cached; - } - const bound = (...args: Args) => handler(index, ...args); - cache.set(index, bound); - return bound; - }, - [handler], - ); + return useStableIndexedHandlerImpl(handler as (index: number, ...args: unknown[]) => void) as (index: number) => (...args: Args) => void; } export default useStableIndexedHandler; diff --git a/src/hooks/useStepFormSubmit.ts b/src/hooks/useStepFormSubmit.ts index 85359ccdfc0f..57a5906ec215 100644 --- a/src/hooks/useStepFormSubmit.ts +++ b/src/hooks/useStepFormSubmit.ts @@ -2,16 +2,38 @@ import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; import * as FormActions from '@userActions/FormActions'; -import type {OnyxFormKey, OnyxFormValuesMapping, OnyxValues} from '@src/ONYXKEYS'; -import type {BaseForm} from '@src/types/form/Form'; +import type {OnyxFormKey, OnyxFormValuesMapping} from '@src/ONYXKEYS'; -import type {TupleToUnion} from 'type-fest'; +import type {SubStepProps} from './useSubStep/types'; -import {useCallback} from 'react'; +type UseStepFormSubmitParams = Pick & { + formId: OnyxFormKey; + fieldIds: readonly (string | number | symbol)[]; + shouldSaveDraft: boolean; +}; -import type {SubStepProps} from './useSubStep/types'; +/** + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). + */ +function useStepFormSubmitImpl({formId, onNext, fieldIds, shouldSaveDraft}: UseStepFormSubmitParams) { + return (values: Record) => { + if (shouldSaveDraft) { + const stepValues = fieldIds.reduce>((acc, key) => { + acc[String(key)] = values[String(key)]; + return acc; + }, {}); + + FormActions.setDraftValues(formId, stepValues); + onNext(stepValues); + return; + } + + onNext(); + }; +} -type UseStepFormSubmitParams = Pick & { +type UseStepFormSubmitParamsGeneric = Pick & { formId: OnyxFormKey; fieldIds: Array>; shouldSaveDraft: boolean; @@ -25,25 +47,6 @@ type UseStepFormSubmitParams = Pick({formId, onNext, fieldIds, shouldSaveDraft}: UseStepFormSubmitParams) { - return useCallback( - (values: FormOnyxValues) => { - if (shouldSaveDraft) { - const stepValues = fieldIds.reduce( - (acc, key) => { - acc[key] = values[key]; - return acc; - }, - {} as Record, OnyxValues[T][Exclude]>, - ); - - FormActions.setDraftValues(formId, stepValues); - onNext(stepValues); - return; - } - - onNext(); - }, - [onNext, formId, fieldIds, shouldSaveDraft], - ); +export default function useStepFormSubmit({formId, onNext, fieldIds, shouldSaveDraft}: UseStepFormSubmitParamsGeneric) { + return useStepFormSubmitImpl({formId, onNext, fieldIds, shouldSaveDraft}) as (values: FormOnyxValues) => void; } diff --git a/src/hooks/useSubPage/index.tsx b/src/hooks/useSubPage/index.tsx index 9021bf95edce..a16a7665533b 100644 --- a/src/hooks/useSubPage/index.tsx +++ b/src/hooks/useSubPage/index.tsx @@ -4,27 +4,15 @@ import {findLastPageIndex, findPageIndex} from '@libs/SubPageUtils'; import type {ComponentType} from 'react'; import {useNavigation, useRoute} from '@react-navigation/native'; -import {useCallback, useEffect} from 'react'; +import {useEffect} from 'react'; import type {SubPageProps, UseSubPageProps} from './types'; /** - * @param pages - array of objects with pageName and component to display in each page - * @param onFinished - callback triggered after finishing the last page - * @param startFrom - index of the page to start from (used when no subPage param in URL) - * @param onPageChange - callback triggered after finishing each page - * @param skipPages - array of page names to skip - * @param buildRoute - function that returns the route for a given page name and optional action + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -export default function useSubPage({ - pages, - onFinished, - startFrom = 0, - skipPages = [], - onPageChange = () => {}, - buildRoute, - shouldReplaceRoute = false, -}: UseSubPageProps) { +function useSubPageImpl({pages, onFinished, startFrom = 0, skipPages = [], onPageChange = () => {}, buildRoute, shouldReplaceRoute = false}: UseSubPageProps) { const navigation = useNavigation(); const route = useRoute(); const params = route.params as {subPage?: string; action?: 'edit'} | undefined; @@ -47,18 +35,15 @@ export default function useSubPage { - if (shouldReplaceRoute) { - Navigation.navigate(buildRoute(pageName, action), {forceReplace: true}); - return; - } - Navigation.navigate(buildRoute(pageName, action)); - }, - [buildRoute, shouldReplaceRoute], - ); + const navigateToPage = (pageName: string, action?: 'edit') => { + if (shouldReplaceRoute) { + Navigation.navigate(buildRoute(pageName, action), {forceReplace: true}); + return; + } + Navigation.navigate(buildRoute(pageName, action)); + }; - const prevPage = useCallback(() => { + const prevPage = () => { let targetIndex = pageIndex - 1; while (targetIndex >= 0) { const targetIndexPageName = pages.at(targetIndex)?.pageName; @@ -82,58 +67,49 @@ export default function useSubPage { - if (isEditing && lastPageName) { - navigateToPage(lastPageName); - return; - } + const nextPage = (finishData?: unknown) => { + if (isEditing && lastPageName) { + navigateToPage(lastPageName); + return; + } - let targetIndex = pageIndex + 1; - while (targetIndex < pages.length) { - const targetIndexPageName = pages.at(targetIndex)?.pageName; - if (!targetIndexPageName || !skipPages.includes(targetIndexPageName)) { - break; - } - targetIndex += 1; + let targetIndex = pageIndex + 1; + while (targetIndex < pages.length) { + const targetIndexPageName = pages.at(targetIndex)?.pageName; + if (!targetIndexPageName || !skipPages.includes(targetIndexPageName)) { + break; } + targetIndex += 1; + } - if (targetIndex > lastPageIndex) { - onFinished(finishData); - } else { - const targetPage = pages.at(targetIndex); - if (targetPage) { - onPageChange(); - navigateToPage(targetPage.pageName); - } - } - }, - [isEditing, lastPageName, navigateToPage, pageIndex, pages, skipPages, lastPageIndex, onFinished, onPageChange], - ); - - const moveTo = useCallback( - (step: number, turnOnEditMode?: boolean) => { - const pageName = pages.at(step)?.pageName; - if (!pageName) { - return; - } - const shouldEdit = !(turnOnEditMode !== undefined && !turnOnEditMode); - navigateToPage(pageName, shouldEdit ? 'edit' : undefined); - }, - [pages, navigateToPage], - ); - - const resetToPage = useCallback( - (pageName?: TPageName) => { - const targetPage = pageName ?? pages.at(0)?.pageName; + if (targetIndex > lastPageIndex) { + onFinished(finishData); + } else { + const targetPage = pages.at(targetIndex); if (targetPage) { - navigateToPage(targetPage); + onPageChange(); + navigateToPage(targetPage.pageName); } - }, - [pages, navigateToPage], - ); + } + }; + + const moveTo = (step: number, turnOnEditMode?: boolean) => { + const pageName = pages.at(step)?.pageName; + if (!pageName) { + return; + } + const shouldEdit = !(turnOnEditMode !== undefined && !turnOnEditMode); + navigateToPage(pageName, shouldEdit ? 'edit' : undefined); + }; + + const resetToPage = (pageName?: string) => { + const targetPage = pageName ?? pages.at(0)?.pageName; + if (targetPage) { + navigateToPage(targetPage); + } + }; if (pages.length === skipPages.length) { throw new Error('All pages are skipped'); @@ -144,7 +120,7 @@ export default function useSubPage, + CurrentPage: currentPage?.component as ComponentType, isEditing, currentPageName, pageIndex, @@ -156,3 +132,18 @@ export default function useSubPage(props: UseSubPageProps) { + return useSubPageImpl(props as unknown as UseSubPageProps) as ReturnType & { + CurrentPage: ComponentType; + resetToPage: (pageName?: TPageName) => void; + }; +} diff --git a/src/hooks/useWorkletStateMachine/index.ts b/src/hooks/useWorkletStateMachine/index.ts index 3cc26ce972ac..43925e03df1a 100644 --- a/src/hooks/useWorkletStateMachine/index.ts +++ b/src/hooks/useWorkletStateMachine/index.ts @@ -1,7 +1,6 @@ import Log from '@libs/Log'; import {fastMerge} from 'expensify-common'; -import {useCallback} from 'react'; import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; @@ -35,6 +34,83 @@ type StateMachine = Partia // eslint-disable-next-line @typescript-eslint/unbound-method const client = (...args: Parameters) => scheduleOnRN(Log.client, ...args); +/** + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). + */ +function useWorkletStateMachineImpl(stateMachine: StateMachine, initialState: State) { + const currentState = useSharedValue(initialState); + + const log = (message: string, params?: unknown) => { + 'worklet'; + + if (!DEBUG_MODE) { + return; + } + + client(`[StateMachine] ${message}. Params: ${JSON.stringify(params)}`); + }; + + const transitionWorklet = (action: ActionWithPayload) => { + 'worklet'; + + if (!action) { + throw new Error('state machine action is required'); + } + + const state = currentState.get(); + + log(`Current STATE: ${state.current.state}`); + log(`Next ACTION: ${action.type}`, action.payload); + + const nextMachine = stateMachine[state.current.state]; + if (!nextMachine) { + log(`No next machine found for state: ${state.current.state}`); + return; + } + + const nextState = nextMachine[action.type]; + if (!nextState) { + log(`No next state found for action: ${action.type}`); + return; + } + + // save previous payload or merge the new payload with the previous payload + const nextPayload = typeof action.payload === 'undefined' ? state.current.payload : fastMerge(state.current.payload, action.payload); + log(`Next STATE: ${nextState}`, nextPayload); + + currentState.set({ + previous: state.current, + current: { + state: nextState, + payload: nextPayload, + }, + }); + }; + + const resetWorklet = () => { + 'worklet'; + + log('RESET STATE MACHINE'); + currentState.set(initialState); + }; + + const reset = () => { + scheduleOnUI(resetWorklet); + }; + + const transition = (action: ActionWithPayload) => { + runOnUISync(transitionWorklet, action); + }; + + return { + currentState, + transitionWorklet, + transition, + reset, + }; +} + /** * A hook that creates a state machine that can be used with Reanimated Worklets, useful for when you need to keep the native thread and JS tightly in-sync. * You can transition state from worklets running on the UI thread, or from the JS thread. @@ -87,81 +163,9 @@ const client = (...args: Parameters) => scheduleOnRN(Log.clie * @returns an object containing the current state, a transition function, and a reset function */ function useWorkletStateMachine, P>(stateMachine: SM, initialState: State

) { - const currentState = useSharedValue(initialState); - - const log = useCallback((message: string, params?: P | null) => { - 'worklet'; - - if (!DEBUG_MODE) { - return; - } - - client(`[StateMachine] ${message}. Params: ${JSON.stringify(params)}`); - }, []); - - const transitionWorklet = useCallback( - (action: ActionWithPayload

) => { - 'worklet'; - - if (!action) { - throw new Error('state machine action is required'); - } - - const state = currentState.get(); - - log(`Current STATE: ${state.current.state}`); - log(`Next ACTION: ${action.type}`, action.payload); - - const nextMachine = stateMachine[state.current.state]; - if (!nextMachine) { - log(`No next machine found for state: ${state.current.state}`); - return; - } - - const nextState = nextMachine[action.type]; - if (!nextState) { - log(`No next state found for action: ${action.type}`); - return; - } - - // save previous payload or merge the new payload with the previous payload - const nextPayload = typeof action.payload === 'undefined' ? state.current.payload : fastMerge(state.current.payload, action.payload); - log(`Next STATE: ${nextState}`, nextPayload); - - currentState.set({ - previous: state.current, - current: { - state: nextState, - payload: nextPayload, - }, - }); - }, - [currentState, log, stateMachine], - ); - - const resetWorklet = useCallback(() => { - 'worklet'; - - log('RESET STATE MACHINE'); - currentState.set(initialState); - }, [currentState, initialState, log]); - - const reset = useCallback(() => { - scheduleOnUI(resetWorklet); - }, [resetWorklet]); - - const transition = useCallback( - (action: ActionWithPayload

) => { - runOnUISync(transitionWorklet, action); - }, - [transitionWorklet], - ); - - return { - currentState, - transitionWorklet, - transition, - reset, + return useWorkletStateMachineImpl(stateMachine, initialState as State) as ReturnType & { + transitionWorklet: (action: ActionWithPayload

) => void; + transition: (action: ActionWithPayload

) => void; }; } diff --git a/src/pages/inbox/report/useDebouncedSaveDraft.ts b/src/pages/inbox/report/useDebouncedSaveDraft.ts index 7e6a8bfa0e82..e75e801bd820 100644 --- a/src/pages/inbox/report/useDebouncedSaveDraft.ts +++ b/src/pages/inbox/report/useDebouncedSaveDraft.ts @@ -2,39 +2,32 @@ import useDebounce from '@hooks/useDebounce'; import CONST from '@src/CONST'; -import {useCallback, useEffect, useRef} from 'react'; +import type {RefObject} from 'react'; + +import {useEffect, useRef} from 'react'; + +type UseDebouncedSaveDraftResult = { + saveDraft: (...args: unknown[]) => void; + isSavePending: RefObject; +}; /** - * Debounces a function to save a draft for a report comment or report action draft. - * @param saveDraft - The function to save the draft. It will be called with the arguments passed to the triggerSaveDraft function. - * @returns An object containing the debounced save draft function, the trigger save draft function, and the is save pending ref. - * @property {Function} debouncedSaveDraft - The debounced save draft function. - * @property {Function} triggerSaveDraft - The trigger save draft function. - * @property {Ref} isSavePending - The ref to check whether the save is pending. + * Non-generic implementation so OXC's React Compiler can memoize the hook. + * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). */ -function useDebouncedSaveDraft(saveDraftFn: (...args: SaveDraftArgs) => void, wait = CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME) { +function useDebouncedSaveDraftImpl(saveDraftFn: (...args: unknown[]) => void, wait = CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME): UseDebouncedSaveDraftResult { const isSavePending = useRef(false); - const debouncedSaveDraft = useDebounce( - useCallback( - (...args: SaveDraftArgs) => { - saveDraftFn(...args); - isSavePending.current = false; - }, - [saveDraftFn], - ), - wait, - ); + const debouncedSaveDraft = useDebounce((...args: unknown[]) => { + saveDraftFn(...args); + isSavePending.current = false; + }, wait); - const saveDraft = useCallback( - (...args: SaveDraftArgs) => { - isSavePending.current = true; - debouncedSaveDraft(...args); - }, - [debouncedSaveDraft], - ); + const saveDraft = (...args: unknown[]) => { + isSavePending.current = true; + debouncedSaveDraft(...args); + }; - // Cancel the debounced save draft on unmount useEffect( () => () => { isSavePending.current = false; @@ -48,4 +41,19 @@ function useDebouncedSaveDraft(saveDraftFn: (.. }; } +/** + * Debounces a function to save a draft for a report comment or report action draft. + * @param saveDraft - The function to save the draft. It will be called with the arguments passed to the triggerSaveDraft function. + * @returns An object containing the debounced save draft function, the trigger save draft function, and the is save pending ref. + * @property {Function} debouncedSaveDraft - The debounced save draft function. + * @property {Function} triggerSaveDraft - The trigger save draft function. + * @property {Ref} isSavePending - The ref to check whether the save is pending. + */ +function useDebouncedSaveDraft(saveDraftFn: (...args: SaveDraftArgs) => void, wait = CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME) { + return useDebouncedSaveDraftImpl(saveDraftFn as (...args: unknown[]) => void, wait) as { + saveDraft: (...args: SaveDraftArgs) => void; + isSavePending: RefObject; + }; +} + export default useDebouncedSaveDraft; From cdaeebafa885b108e48dd7105bdb7b4990b55c9a Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 13:12:34 -0700 Subject: [PATCH 06/11] fix: hoist HOC Impls for OXC auto-memo 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 --- .../AnimatedFlatListWithCellRenderer.tsx | 87 +++++----- src/components/Button/index.tsx | 105 +++++------- .../ButtonWithDropdownMenu/index.tsx | 59 +++---- .../createScreenWithDefaults.tsx | 25 ++- src/components/createOnyxContext.tsx | 26 ++- .../withCurrentUserPersonalDetails.tsx | 25 ++- src/components/withNavigationFallback.tsx | 48 +++--- .../withNavigationTransitionEnd.tsx | 44 +++-- src/components/withToggleVisibilityView.tsx | 38 +++-- src/components/withViewportOffsetTop.tsx | 52 +++--- .../index.native.tsx | 66 ++++++-- .../index.tsx | 66 ++++++-- src/libs/genericMemo.ts | 16 -- .../withReportAndPrivateNotesOrNotFound.tsx | 155 +++++++++--------- .../withReportAndReportActionOrNotFound.tsx | 131 ++++++++------- .../step/withFullTransactionOrNotFound.tsx | 85 ++++++---- .../step/withWritableReportOrNotFound.tsx | 95 ++++++----- src/pages/workspace/withPolicy.tsx | 53 +++--- .../withPolicyAndFullscreenLoading.tsx | 66 +++++--- src/pages/workspace/withPolicyConnections.tsx | 71 ++++---- 20 files changed, 757 insertions(+), 556 deletions(-) delete mode 100644 src/libs/genericMemo.ts diff --git a/src/components/AnimatedFlatListWithCellRenderer.tsx b/src/components/AnimatedFlatListWithCellRenderer.tsx index 7ab4867d23dd..e63af64a451d 100644 --- a/src/components/AnimatedFlatListWithCellRenderer.tsx +++ b/src/components/AnimatedFlatListWithCellRenderer.tsx @@ -1,5 +1,3 @@ -import genericMemo from '@libs/genericMemo'; - /** * This is a copy of the FlatList implementation from 'react-native-reanimated' in order to implement a custom CellRendererComponent. * This should be updated when the original implementation updates @@ -9,7 +7,7 @@ import type {Ref} from 'react'; import type {FlatListProps, CellRendererProps as RNCellRendererProps} from 'react-native'; import type {AnimatedProps, ILayoutAnimationBuilder} from 'react-native-reanimated'; -import React, {useRef} from 'react'; +import React, {createContext, useContext} from 'react'; import {FlatList} from 'react-native'; import Animated, {LayoutAnimationConfig} from 'react-native-reanimated'; @@ -17,24 +15,34 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); type CellRendererComponentProps = React.ComponentType> | null | undefined; -const createCellRendererComponent = (CellRendererComponentProp?: CellRendererComponentProps, itemLayoutAnimationRef?: React.RefObject) => { - // Make CellRendererComponent specifically use the 'Item' type from its parent scope - function CellRendererComponent(props: RNCellRendererProps) { - return ( - - {CellRendererComponentProp ? {props.children} : props.children} - - ); - } - - return CellRendererComponent; +type CellRendererConfig = { + itemLayoutAnimation?: ILayoutAnimationBuilder; + outerCellRenderer?: CellRendererComponentProps; }; + +const CellRendererConfigContext = createContext({}); + +/** + * Module-scope cell renderer so OXC's React Compiler can discover and memoize it. + * `itemLayoutAnimation` and the optional outer renderer are read from context because + * FlatList only passes standard cell props to `CellRendererComponent`. + */ +function CellRendererComponentImpl(props: RNCellRendererProps) { + const {itemLayoutAnimation, outerCellRenderer: OuterCellRenderer} = useContext(CellRendererConfigContext); + + return ( + + {OuterCellRenderer ? {props.children} : props.children} + + ); +} + type ReanimatedFlatListPropsWithLayout = { /** * Lets you pass layout animation directly to the FlatList item. @@ -57,10 +65,13 @@ type AnimatedFlatListWithCellRendererProps = Omit; }; -// We need explicit any here, because this is the exact same type that is used in React Native types. +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function FlatListRender(props: AnimatedFlatListWithCellRendererProps) { - const {itemLayoutAnimation, skipEnteringExitingAnimations, ref, ...restProps} = props; +function FlatListRenderImpl(props: AnimatedFlatListWithCellRendererProps) { + const {itemLayoutAnimation, skipEnteringExitingAnimations, ref, CellRendererComponent: outerCellRenderer, ...restProps} = props; // Set default scrollEventThrottle, because user expects // to have continuous scroll events and @@ -71,22 +82,16 @@ function FlatListRender(props: AnimatedFlatListWithCellRendererProps restProps.scrollEventThrottle = 1; } - const itemLayoutAnimationRef = useRef(itemLayoutAnimation); - itemLayoutAnimationRef.current = itemLayoutAnimation; - - const CellRendererComponent = React.useMemo( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => createCellRendererComponent(props.CellRendererComponent, itemLayoutAnimationRef), - [props.CellRendererComponent], - ); + const cellRendererConfig: CellRendererConfig = {itemLayoutAnimation, outerCellRenderer}; const animatedFlatList = ( - // @ts-expect-error In its current type state, createAnimatedComponent cannot create generic components. - + + + ); if (skipEnteringExitingAnimations === undefined) { @@ -103,7 +108,13 @@ function FlatListRender(props: AnimatedFlatListWithCellRendererProps ); } -const AnimatedFlatListWithCellRenderer = genericMemo(FlatListRender) as < +// We need explicit any here, because this is the exact same type that is used in React Native types. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function FlatListRender(props: AnimatedFlatListWithCellRendererProps) { + return )} />; +} + +const AnimatedFlatListWithCellRenderer = FlatListRender as < // We need explicit any here, because this is the exact same type that is used in React Native types. // eslint-disable-next-line @typescript-eslint/no-explicit-any ItemT = any, diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index ac36c4d29814..cb33ce2c1715 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -26,7 +26,7 @@ import type {ForwardedRef} from 'react'; import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {StyleSheet, View} from 'react-native'; import {getButtonRole} from './utils'; @@ -214,28 +214,21 @@ function KeyboardShortcutComponent({ const isFocused = useIsFocused(); const activeElementRole = useActiveElementRole(); - const shouldDisableEnterShortcut = useMemo(() => accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ROLE.PRESENTATION, [activeElementRole]); + const shouldDisableEnterShortcut = accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ROLE.PRESENTATION; - const keyboardShortcutCallback = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - if (!validateSubmitShortcut(isDisabled, isLoading, event)) { - return; - } - onPress(); - }, - [isDisabled, isLoading, onPress], - ); + const keyboardShortcutCallback = (event?: GestureResponderEvent | KeyboardEvent) => { + if (!validateSubmitShortcut(isDisabled, isLoading, event)) { + return; + } + onPress(); + }; - const config = useMemo( - () => ({ - isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), - shouldBubble: allowBubble, - priority: enterKeyEventListenerPriority, - shouldPreventDefault: false, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [shouldDisableEnterShortcut, isFocused], - ); + const config = { + isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), + shouldBubble: allowBubble, + priority: enterKeyEventListenerPriority, + shouldPreventDefault: false, + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, config); @@ -439,55 +432,33 @@ function Button({ buttonSize = CONST.DROPDOWN_BUTTON_SIZE.LARGE; } - const buttonStyles = useMemo>( - () => [ - styles.button, - StyleUtils.getButtonStyleWithIcon(styles, buttonSize, !!icon, !!(text?.length > 0), shouldShowRightIcon), - success ? styles.buttonSuccess : undefined, - danger ? styles.buttonDanger : undefined, - isDisabled && !shouldStayNormalOnDisable ? styles.buttonOpacityDisabled : undefined, - isDisabled && !danger && !success && !shouldStayNormalOnDisable ? styles.buttonDisabled : undefined, - shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, - shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - text && shouldShowRightIcon ? styles.alignItemsStretch : undefined, - innerStyles, - link && styles.bgTransparent, - ], - [ - StyleUtils, - danger, - icon, - innerStyles, - isDisabled, - buttonSize, - link, - shouldRemoveLeftBorderRadius, - shouldRemoveRightBorderRadius, - shouldShowRightIcon, - styles, - success, - text, - shouldStayNormalOnDisable, - ], - ); - - const buttonContainerStyles = useMemo>( - () => [buttonStyles, shouldBlendOpacity && styles.buttonBlendContainer], - [buttonStyles, shouldBlendOpacity, styles.buttonBlendContainer], - ); - - const buttonBlendForegroundStyle = useMemo>(() => { - if (!shouldBlendOpacity) { - return undefined; - } - + const buttonStyles: StyleProp = [ + styles.button, + StyleUtils.getButtonStyleWithIcon(styles, buttonSize, !!icon, !!(text?.length > 0), shouldShowRightIcon), + success ? styles.buttonSuccess : undefined, + danger ? styles.buttonDanger : undefined, + isDisabled && !shouldStayNormalOnDisable ? styles.buttonOpacityDisabled : undefined, + isDisabled && !danger && !success && !shouldStayNormalOnDisable ? styles.buttonDisabled : undefined, + shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, + shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, + text && shouldShowRightIcon ? styles.alignItemsStretch : undefined, + innerStyles, + link && styles.bgTransparent, + ]; + + const buttonContainerStyles: StyleProp = [buttonStyles, shouldBlendOpacity && styles.buttonBlendContainer]; + + let buttonBlendForegroundStyle: StyleProp; + if (!shouldBlendOpacity) { + buttonBlendForegroundStyle = undefined; + } else { const {backgroundColor, opacity} = StyleSheet.flatten(buttonStyles); - return { + buttonBlendForegroundStyle = { backgroundColor, opacity, }; - }, [buttonStyles, shouldBlendOpacity]); + } let loadingIndicatorColor = theme.text; if (danger) { @@ -589,8 +560,6 @@ function Button({ ); } -// OXC's React Compiler bails on this file (missing memoization dependencies), so Button is not -// memoized on web. Memoize it explicitly to keep parent-driven re-renders cheap there. -export default withNavigationFallback(React.memo(Button)); +export default withNavigationFallback(Button); export type {ButtonProps}; diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index e4460226ed6a..87d37081afc0 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -12,7 +12,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import genericMemo from '@libs/genericMemo'; import mergeRefs from '@libs/mergeRefs'; import variables from '@styles/variables'; @@ -20,11 +19,10 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; -import type {RefObject} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ButtonWithDropdownMenuProps} from './types'; @@ -112,7 +110,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor); + const dropdownButtonRef = mergeRefs(buttonRef, isSplitButton ? undefined : dropdownAnchor); const selectedItem = options.at(selectedItemIndex) ?? options.at(0); const areAllOptionsDisabled = options.every((option) => option.disabled); const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(size); @@ -125,7 +123,6 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM } else if (!shouldUseShortForm) { dropdownArrowIconSize = CONST.ICON_SIZE.SMALL; } - const nullCheckRef = (refParam: RefObject) => refParam ?? null; const shouldShowButtonRightIcon = !!options.at(0)?.shouldShowButtonRightIcon; const splitButtonIcon = hasError ? icons.DotIndicator : icon; const singleOptionButtonIcon = shouldUseOptionIcon && !shouldShowButtonRightIcon ? options.at(0)?.icon : icon; @@ -147,24 +144,21 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM calculatePopoverPosition(dropdownAnchor, anchorAlignment).then(setPopoverAnchorPosition); }, [isMenuVisible, calculatePopoverPosition, anchorAlignment]); - const handleSingleOptionPress = useCallback( - (event: GestureResponderEvent | KeyboardEvent | undefined) => { - const option = options.at(0); - if (!option) { - return; - } + const handleSingleOptionPress = (event: GestureResponderEvent | KeyboardEvent | undefined) => { + const option = options.at(0); + if (!option) { + return; + } - if (option.onSelected) { - option.onSelected(); - } else { - onOptionSelected?.(option); - onPress(event, option.value); - } + if (option.onSelected) { + option.onSelected(); + } else { + onOptionSelected?.(option); + onPress(event, option.value); + } - onSubItemSelected?.(option, 0, event); - }, - [options, onPress, onOptionSelected, onSubItemSelected], - ); + onSubItemSelected?.(option, 0, event); + }; useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, @@ -198,16 +192,13 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM const nonSplitButtonStyle = buttonStyle ? [styles.w100, buttonStyle] : defaultStyle; const isTextTooLong = customText && customText?.length > 6; - const handlePress = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - if (!isSplitButton) { - setIsMenuVisible(!isMenuVisible); - } else if (selectedItem?.value) { - onPress(event, selectedItem.value); - } - }, - [isMenuVisible, isSplitButton, onPress, selectedItem?.value], - ); + const handlePress = (event?: GestureResponderEvent | KeyboardEvent) => { + if (!isSplitButton) { + setIsMenuVisible(!isMenuVisible); + } else if (selectedItem?.value) { + onPress(event, selectedItem.value); + } + }; useImperativeHandle(ref, () => ({ setIsMenuVisible, @@ -352,7 +343,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM }} anchorPosition={popoverAnchorPosition} shouldShowRadioButton={shouldShowRadioButton} - anchorRef={nullCheckRef(dropdownAnchor)} + anchorRef={dropdownAnchor} scrollContainerStyle={!shouldUseModalPaddingStyle && isSmallScreenWidth && {...styles.pt4, paddingBottom}} anchorAlignment={anchorAlignment} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} @@ -388,6 +379,4 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM ); } -// OXC's React Compiler bails on this file (refs accessed during render), so it is not memoized on -// web. Memoize it explicitly (genericMemo preserves the generic call signature). -export default genericMemo(ButtonWithDropdownMenu); +export default ButtonWithDropdownMenu; diff --git a/src/components/MultifactorAuthentication/components/OutcomeScreen/createScreenWithDefaults.tsx b/src/components/MultifactorAuthentication/components/OutcomeScreen/createScreenWithDefaults.tsx index 9246c8cd193b..755b19375e6a 100644 --- a/src/components/MultifactorAuthentication/components/OutcomeScreen/createScreenWithDefaults.tsx +++ b/src/components/MultifactorAuthentication/components/OutcomeScreen/createScreenWithDefaults.tsx @@ -31,17 +31,32 @@ import React from 'react'; * // Override only the title (illustration and subtitle keep their defaults): * */ +type ScreenWithDefaultsImplProps

> = { + Component: React.ComponentType

; + defaultProps: NoInfer

; + overrideProps: Partial

; +}; + +function ScreenWithDefaultsImpl

>({Component, defaultProps, overrideProps}: ScreenWithDefaultsImplProps

) { + const mergedProps: P = {...defaultProps, ...overrideProps}; + + return ; +} + function createScreenWithDefaults

>(Component: React.ComponentType

, defaultProps: NoInfer

, displayName: string): React.ComponentType> { function Screen(overrideProps: Partial

) { - const mergedProps: P = {...defaultProps, ...overrideProps}; - - return ; + return ( + + ); } Screen.displayName = displayName; - // OXC's React Compiler does not memoize this generated component on web; memoize it explicitly. - return React.memo(Screen); + return Screen; } export default createScreenWithDefaults; diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx index fff8fdb2f11d..226ae5602d68 100644 --- a/src/components/createOnyxContext.tsx +++ b/src/components/createOnyxContext.tsx @@ -3,7 +3,7 @@ import useOnyx from '@hooks/useOnyx'; import type {OnyxKey} from '@src/ONYXKEYS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {ComponentType, ReactNode} from 'react'; +import type {ComponentType, Context, ReactNode} from 'react'; import type {OnyxValue} from 'react-native-onyx'; import {Str} from 'expensify-common'; @@ -12,18 +12,30 @@ import React, {createContext, useContext} from 'react'; // createOnyxContext return type type CreateOnyxContext = [ComponentType, React.Context>, () => OnyxValue]; +type OnyxContextProviderImplProps = ChildrenProps & { + onyxKeyName: TOnyxKey; + Context: Context>; +}; + +function OnyxContextProviderImpl({onyxKeyName, Context: OnyxContext, children}: OnyxContextProviderImplProps): ReactNode { + const [value] = useOnyx(onyxKeyName); + return }>{children}; +} + export default (onyxKeyName: TOnyxKey): CreateOnyxContext => { const Context = createContext>(null as OnyxValue); function Provider(props: ChildrenProps): ReactNode { - const [value] = useOnyx(onyxKeyName); - return }>{props.children}; + return ( + + ); } Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`; - // OXC's React Compiler does not memoize this generated Provider on web; memoize it explicitly. - const MemoizedProvider = React.memo(Provider); - const useOnyxContext = () => { const value = useContext(Context); if (value === null) { @@ -32,5 +44,5 @@ export default (onyxKeyName: TOnyxKey): CreateOnyxCont return value as OnyxValue; }; - return [MemoizedProvider, Context, useOnyxContext]; + return [Provider, Context, useOnyxContext]; }; diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx index be35a217056a..30313dfc052a 100644 --- a/src/components/withCurrentUserPersonalDetails.tsx +++ b/src/components/withCurrentUserPersonalDetails.tsx @@ -14,22 +14,33 @@ type HOCProps = { type WithCurrentUserPersonalDetailsProps = HOCProps; +type WithCurrentUserPersonalDetailsImplProps = { + WrappedComponent: ComponentType; +} & Omit; + +function WithCurrentUserPersonalDetailsImpl({WrappedComponent, ...props}: WithCurrentUserPersonalDetailsImplProps) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + return ( + + ); +} + export default function (WrappedComponent: ComponentType): ComponentType> { function WithCurrentUserPersonalDetails(props: Omit) { - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); return ( - ); } WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`; - // OXC's React Compiler does not memoize this component on web, so memoize it explicitly to keep - // parent-driven re-renders cheap on both platforms. - return React.memo(WithCurrentUserPersonalDetails); + return WithCurrentUserPersonalDetails; } export type {WithCurrentUserPersonalDetailsProps}; diff --git a/src/components/withNavigationFallback.tsx b/src/components/withNavigationFallback.tsx index a2c3f827313c..6f1f63354801 100644 --- a/src/components/withNavigationFallback.tsx +++ b/src/components/withNavigationFallback.tsx @@ -3,7 +3,7 @@ import type {ParamListBase} from '@react-navigation/routers'; import type {ComponentType} from 'react'; import {NavigationContext} from '@react-navigation/core'; -import React, {useContext, useMemo} from 'react'; +import React, {useContext} from 'react'; type AddListenerCallback = () => void; @@ -15,29 +15,39 @@ type NavigationContextValue = { removeListener: () => RemoveListenerCallback; }; +const FALLBACK_NAVIGATION_CONTEXT_VALUE: NavigationContextValue = { + isFocused: () => true, + addListener: () => () => {}, + removeListener: () => () => {}, +}; + +type WithNavigationFallbackImplProps> = { + WrappedComponent: ComponentType; +} & TProps; + +function WithNavigationFallbackImpl>({WrappedComponent, ...props}: WithNavigationFallbackImplProps) { + const context = useContext(NavigationContext); + + return context ? ( + + ) : ( + }> + + + ); +} + export default function >(WrappedComponent: ComponentType): ComponentType { function WithNavigationFallback(props: TProps) { - const context = useContext(NavigationContext); - - const navigationContextValue: NavigationContextValue = useMemo( - () => ({ - isFocused: () => true, - addListener: () => () => {}, - removeListener: () => () => {}, - }), - [], - ); - - return context ? ( - - ) : ( - }> - - + return ( + ); } WithNavigationFallback.displayName = `WithNavigationFallback(${WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'})`; - return React.memo(WithNavigationFallback); + return WithNavigationFallback; } diff --git a/src/components/withNavigationTransitionEnd.tsx b/src/components/withNavigationTransitionEnd.tsx index ecd8f46a48bc..c9e3c23440ce 100644 --- a/src/components/withNavigationTransitionEnd.tsx +++ b/src/components/withNavigationTransitionEnd.tsx @@ -9,32 +9,44 @@ import React, {useEffect, useState} from 'react'; type WithNavigationTransitionEndProps = {didScreenTransitionEnd: boolean}; +type WithNavigationTransitionEndImplProps = { + WrappedComponent: ComponentType; +} & TProps; + +function WithNavigationTransitionEndImpl({WrappedComponent, ...props}: WithNavigationTransitionEndImplProps) { + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const navigation = useNavigation>(); + + useEffect(() => { + const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { + setDidScreenTransitionEnd(true); + }); + + return unsubscribeTransitionEnd; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + ); +} + export default function (WrappedComponent: ComponentType): React.ComponentType { function WithNavigationTransitionEnd(props: TProps) { - const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const navigation = useNavigation>(); - - useEffect(() => { - const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { - setDidScreenTransitionEnd(true); - }); - - return unsubscribeTransitionEnd; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( - ); } WithNavigationTransitionEnd.displayName = `WithNavigationTransitionEnd(${getComponentDisplayName(WrappedComponent)})`; - // OXC's React Compiler does not memoize this component on web; memoize it explicitly. - return React.memo(WithNavigationTransitionEnd); + return WithNavigationTransitionEnd; } export type {WithNavigationTransitionEndProps}; diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index 48b9e020283c..b8b6416bcbea 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -12,25 +12,39 @@ type WithToggleVisibilityViewProps = { isVisible?: boolean; }; +type WithToggleVisibilityViewImplProps = { + WrappedComponent: ComponentType; +} & TProps & + WithToggleVisibilityViewProps; + +function WithToggleVisibilityViewImpl({WrappedComponent, isVisible = false, ...rest}: WithToggleVisibilityViewImplProps) { + const styles = useThemeStyles(); + return ( + + + + ); +} + export default function withToggleVisibilityView(WrappedComponent: ComponentType): ComponentType { - function WithToggleVisibilityView({isVisible = false, ...rest}: WithToggleVisibilityViewProps) { - const styles = useThemeStyles(); + function WithToggleVisibilityView(props: TProps & WithToggleVisibilityViewProps) { return ( - - - + ); } WithToggleVisibilityView.displayName = `WithToggleVisibilityViewWithRef(${getComponentDisplayName(WrappedComponent)})`; - return React.memo(WithToggleVisibilityView); + return WithToggleVisibilityView; } export type {WithToggleVisibilityViewProps}; diff --git a/src/components/withViewportOffsetTop.tsx b/src/components/withViewportOffsetTop.tsx index 167c388cd366..c9fe053c3a91 100644 --- a/src/components/withViewportOffsetTop.tsx +++ b/src/components/withViewportOffsetTop.tsx @@ -11,33 +11,45 @@ type ViewportOffsetTopProps = { viewportOffsetTop: number; }; +type WithViewportOffsetTopImplProps = { + WrappedComponent: ComponentType; +} & Omit; + +function WithViewportOffsetTopImpl({WrappedComponent, ...props}: WithViewportOffsetTopImplProps) { + const [viewportOffsetTop, setViewportOffsetTop] = useState(0); + + useEffect(() => { + const updateDimensions = (event: Event) => { + const targetOffsetTop = (event.target instanceof VisualViewport && event.target.offsetTop) || 0; + setViewportOffsetTop(targetOffsetTop); + }; + + const removeViewportResizeListener = addViewportResizeListener(updateDimensions); + + return () => { + removeViewportResizeListener(); + }; + }, []); + + return ( + + ); +} + export default function withViewportOffsetTop(WrappedComponent: ComponentType) { function WithViewportOffsetTop(props: Omit) { - const [viewportOffsetTop, setViewportOffsetTop] = useState(0); - - useEffect(() => { - const updateDimensions = (event: Event) => { - const targetOffsetTop = (event.target instanceof VisualViewport && event.target.offsetTop) || 0; - setViewportOffsetTop(targetOffsetTop); - }; - - const removeViewportResizeListener = addViewportResizeListener(updateDimensions); - - return () => { - removeViewportResizeListener(); - }; - }, []); - return ( - ); } WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`; - // OXC's React Compiler does not memoize this component on web; memoize it explicitly. - return React.memo(WithViewportOffsetTop); + return WithViewportOffsetTop; } diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx index 76f21a51569f..6e95722263ca 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx @@ -18,19 +18,28 @@ import React, {useMemo} from 'react'; import wrapDescriptorsWithFreeze from './wrapDescriptorsWithFreeze'; -function createPlatformStackNavigatorComponent( - displayName: string, - options?: CreatePlatformStackNavigatorComponentOptions, -) { - const createRouter = options?.createRouter ?? StackRouter; - const defaultScreenOptions = options?.defaultScreenOptions; - const useCustomState = options?.useCustomState ?? (() => undefined); - const useCustomEffects = options?.useCustomEffects ?? (() => undefined); - const ExtraContent = options?.ExtraContent; - const NavigationContentWrapper = options?.NavigationContentWrapper; - const freezeNonTopScreens = options?.freezeNonTopScreens; - - function PlatformNavigator({ +type PlatformNavigatorBindings = { + createRouter: NonNullable['createRouter']>; + useCustomState: NonNullable['useCustomState']>; + defaultScreenOptions?: CreatePlatformStackNavigatorComponentOptions['defaultScreenOptions']; + ExtraContent?: CreatePlatformStackNavigatorComponentOptions['ExtraContent']; + NavigationContentWrapper?: CreatePlatformStackNavigatorComponentOptions['NavigationContentWrapper']; + useCustomEffects: NonNullable['useCustomEffects']>; + freezeNonTopScreens?: boolean; + displayName: string; +}; + +function createPlatformNavigatorImpl({ + createRouter, + useCustomState, + defaultScreenOptions, + ExtraContent, + NavigationContentWrapper, + useCustomEffects, + freezeNonTopScreens, + displayName, +}: PlatformNavigatorBindings) { + function PlatformNavigatorImpl({ id, initialRouteName, screenOptions, @@ -40,7 +49,7 @@ function createPlatformStackNavigatorComponent) { + }: PlatformStackNavigatorProps) { const { navigation, state: originalState, @@ -114,9 +123,34 @@ function createPlatformStackNavigatorComponent{Content}; } - PlatformNavigator.displayName = displayName; - return PlatformNavigator; + return PlatformNavigatorImpl; +} + +function createPlatformStackNavigatorComponent( + displayName: string, + options?: CreatePlatformStackNavigatorComponentOptions, +) { + const PlatformNavigatorImpl = createPlatformNavigatorImpl({ + createRouter: (options?.createRouter ?? StackRouter) as NonNullable['createRouter']>, + useCustomState: (options?.useCustomState ?? (() => undefined)) as NonNullable['useCustomState']>, + defaultScreenOptions: options?.defaultScreenOptions, + ExtraContent: options?.ExtraContent, + NavigationContentWrapper: options?.NavigationContentWrapper, + useCustomEffects: (options?.useCustomEffects ?? (() => undefined)) as NonNullable['useCustomEffects']>, + freezeNonTopScreens: options?.freezeNonTopScreens, + displayName, + }); + + function PlatformNavigator(props: PlatformStackNavigatorProps) { + return ; + } + + // OXC's React Compiler does not memoize this generated navigator on web; memoize it explicitly. + const MemoizedPlatformNavigator = React.memo(PlatformNavigator); + MemoizedPlatformNavigator.displayName = displayName; + + return MemoizedPlatformNavigator; } export default createPlatformStackNavigatorComponent; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index d5ec3064b986..76c9f1cc0ea1 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -18,19 +18,28 @@ import React, {useMemo} from 'react'; import wrapDescriptorsWithFreeze from './wrapDescriptorsWithFreeze'; -function createPlatformStackNavigatorComponent( - displayName: string, - options?: CreatePlatformStackNavigatorComponentOptions, -) { - const createRouter = options?.createRouter ?? StackRouter; - const useCustomState = options?.useCustomState ?? (() => undefined); - const defaultScreenOptions = options?.defaultScreenOptions; - const ExtraContent = options?.ExtraContent; - const NavigationContentWrapper = options?.NavigationContentWrapper; - const useCustomEffects = options?.useCustomEffects ?? (() => undefined); - const freezeNonTopScreens = options?.freezeNonTopScreens; - - function PlatformNavigator({ +type PlatformNavigatorBindings = { + createRouter: NonNullable['createRouter']>; + useCustomState: NonNullable['useCustomState']>; + defaultScreenOptions?: CreatePlatformStackNavigatorComponentOptions['defaultScreenOptions']; + ExtraContent?: CreatePlatformStackNavigatorComponentOptions['ExtraContent']; + NavigationContentWrapper?: CreatePlatformStackNavigatorComponentOptions['NavigationContentWrapper']; + useCustomEffects: NonNullable['useCustomEffects']>; + freezeNonTopScreens?: boolean; + displayName: string; +}; + +function createPlatformNavigatorImpl({ + createRouter, + useCustomState, + defaultScreenOptions, + ExtraContent, + NavigationContentWrapper, + useCustomEffects, + freezeNonTopScreens, + displayName, +}: PlatformNavigatorBindings) { + function PlatformNavigatorImpl({ id, initialRouteName, screenOptions, @@ -41,7 +50,7 @@ function createPlatformStackNavigatorComponent) { + }: PlatformStackNavigatorProps) { const { navigation, state: originalState, @@ -128,9 +137,34 @@ function createPlatformStackNavigatorComponent{Content}; } - PlatformNavigator.displayName = displayName; - return PlatformNavigator; + return PlatformNavigatorImpl; +} + +function createPlatformStackNavigatorComponent( + displayName: string, + options?: CreatePlatformStackNavigatorComponentOptions, +) { + const PlatformNavigatorImpl = createPlatformNavigatorImpl({ + createRouter: (options?.createRouter ?? StackRouter) as NonNullable['createRouter']>, + useCustomState: (options?.useCustomState ?? (() => undefined)) as NonNullable['useCustomState']>, + defaultScreenOptions: options?.defaultScreenOptions, + ExtraContent: options?.ExtraContent, + NavigationContentWrapper: options?.NavigationContentWrapper, + useCustomEffects: (options?.useCustomEffects ?? (() => undefined)) as NonNullable['useCustomEffects']>, + freezeNonTopScreens: options?.freezeNonTopScreens, + displayName, + }); + + function PlatformNavigator(props: PlatformStackNavigatorProps) { + return ; + } + + // OXC's React Compiler does not memoize this generated navigator on web; memoize it explicitly. + const MemoizedPlatformNavigator = React.memo(PlatformNavigator); + MemoizedPlatformNavigator.displayName = displayName; + + return MemoizedPlatformNavigator; } export default createPlatformStackNavigatorComponent; diff --git a/src/libs/genericMemo.ts b/src/libs/genericMemo.ts deleted file mode 100644 index 1e787afc18a3..000000000000 --- a/src/libs/genericMemo.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {memo} from 'react'; - -/** - * `React.memo` typed to preserve a component's generic call signature. - * - * `React.memo` widens a generic function component to a non-generic `MemoExoticComponent`, which - * erases the type parameters callers rely on. Casting the memo helper itself (once, here) lets - * generic components be memoized at their export without a per-file `as typeof Component` cast. - * - * This is used to memoize generic components that OXC's React Compiler does not memoize on web - * ("Unsupported declaration type for hoisting" on nested callbacks typed with the component's type - * parameter), keeping parent-driven re-renders cheap on both platforms. - */ -const genericMemo = memo as (component: T) => T; - -export default genericMemo; diff --git a/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx b/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx index 253ddebd9cac..cbe955ab5f83 100644 --- a/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx +++ b/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx @@ -21,7 +21,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ComponentType} from 'react'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import type {WithReportOrNotFoundProps} from './withReportOrNotFound'; @@ -35,90 +35,99 @@ type WithReportAndPrivateNotesOrNotFoundOnyxProps = { type WithReportAndPrivateNotesOrNotFoundProps = WithReportOrNotFoundProps & WithReportAndPrivateNotesOrNotFoundOnyxProps; +type WithReportAndPrivateNotesOrNotFoundImplProps = { + WrappedComponent: ComponentType; + pageTitle: TranslationPaths; +} & Omit; + +function WithReportAndPrivateNotesOrNotFoundImpl({ + WrappedComponent, + pageTitle, + ...props +}: WithReportAndPrivateNotesOrNotFoundImplProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const [session] = useOnyx(ONYXKEYS.SESSION); + const {route, report, reportLoadingState} = props; + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); + const accountID = ('accountID' in route.params && route.params.accountID) || ''; + const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; + const prevIsOffline = usePrevious(isOffline); + const isReconnecting = prevIsOffline && !isOffline; + const isOtherUserNote = !!accountID && Number(session?.accountID) !== Number(accountID); + const isPrivateNotesFetchFinished = isPrivateNotesFetchTriggered && !reportLoadingState?.isLoadingPrivateNotes; + const isPrivateNotesUndefined = accountID ? report?.privateNotes?.[Number(accountID)]?.note === undefined : isEmptyObject(report?.privateNotes); + + useEffect(() => { + // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. + if ((isPrivateNotesFetchTriggered && !isReconnecting) || isOffline) { + return; + } + + getReportPrivateNote(report?.reportID); + }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isReconnecting]); + + const shouldShowFullScreenLoadingIndicator = !isPrivateNotesFetchFinished; + + const shouldShowNotFoundPage = + isArchivedReport(reportNameValuePairs) || isOtherUserNote || isSelfDM(report) + ? true + : shouldShowFullScreenLoadingIndicator || !isPrivateNotesUndefined || isReconnecting + ? false + : isOffline; + + if (shouldShowFullScreenLoadingIndicator) { + if (isOffline) { + return ( + + Navigation.goBack()} + shouldShowBackButton + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + + + + ); + } + return ; + } + + if (shouldShowNotFoundPage) { + return ; + } + + return ( + + ); +} + export default function (pageTitle: TranslationPaths) { return ( WrappedComponent: ComponentType, ): React.ComponentType> => { function WithReportAndPrivateNotesOrNotFound(props: Omit) { - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const [session] = useOnyx(ONYXKEYS.SESSION); - const {route, report, reportLoadingState} = props; - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); - const accountID = ('accountID' in route.params && route.params.accountID) || ''; - const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; - const prevIsOffline = usePrevious(isOffline); - const isReconnecting = prevIsOffline && !isOffline; - const isOtherUserNote = !!accountID && Number(session?.accountID) !== Number(accountID); - const isPrivateNotesFetchFinished = isPrivateNotesFetchTriggered && !reportLoadingState?.isLoadingPrivateNotes; - const isPrivateNotesUndefined = accountID ? report?.privateNotes?.[Number(accountID)]?.note === undefined : isEmptyObject(report?.privateNotes); - - useEffect(() => { - // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. - if ((isPrivateNotesFetchTriggered && !isReconnecting) || isOffline) { - return; - } - - getReportPrivateNote(report?.reportID); - }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isReconnecting]); - - const shouldShowFullScreenLoadingIndicator = !isPrivateNotesFetchFinished; - - const shouldShowNotFoundPage = useMemo(() => { - // Show not found view if the report is archived, or if the note is not of current user or if report is a self DM. - if (isArchivedReport(reportNameValuePairs) || isOtherUserNote || isSelfDM(report)) { - return true; - } - - // Don't show not found view if the notes are still loading, or if the notes are non-empty. - if (shouldShowFullScreenLoadingIndicator || !isPrivateNotesUndefined || isReconnecting) { - return false; - } - - // As notes being empty and not loading is a valid case, show not found view only in offline mode. - return isOffline; - }, [report, isOtherUserNote, shouldShowFullScreenLoadingIndicator, isPrivateNotesUndefined, isReconnecting, isOffline, reportNameValuePairs]); - - if (shouldShowFullScreenLoadingIndicator) { - if (isOffline) { - return ( - - Navigation.goBack()} - shouldShowBackButton - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - - - ); - } - return ; - } - - if (shouldShowNotFoundPage) { - return ; - } - return ( - ); } WithReportAndPrivateNotesOrNotFound.displayName = `withReportAndPrivateNotesOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - // OXC's React Compiler does not memoize this component on web; memoize it before wrapping so it - // is memoized on both platforms. - return withReportOrNotFound()(React.memo(WithReportAndPrivateNotesOrNotFound)); + return withReportOrNotFound()(WithReportAndPrivateNotesOrNotFound); }; } diff --git a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx index b1b726119425..7cb8592cade2 100644 --- a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx +++ b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx @@ -23,7 +23,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ComponentType} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; type WithReportAndReportActionOrNotFoundProps = PlatformStackScreenProps< FlagCommentNavigatorParamList & SplitDetailsNavigatorParamList, @@ -42,77 +42,86 @@ type WithReportAndReportActionOrNotFoundProps = PlatformStackScreenProps< parentReportAction: NonNullable> | null; }; -export default function (WrappedComponent: ComponentType): ComponentType { - function WithReportOrNotFound(props: TProps) { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.route.params.reportID}`); - - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${props.route.params.reportID}`); - const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${props.route.params.reportID}`); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - - const parentReportAction = useParentReportAction(report); - const linkedReportAction = useMemo(() => { - let reportAction: OnyxEntry = reportActions?.[`${props.route.params.reportActionID}`]; - - // Handle threads if needed - if (!reportAction?.reportActionID) { - reportAction = parentReportAction ?? undefined; - } - - return reportAction; - }, [reportActions, props.route.params.reportActionID, parentReportAction]); - - const {shouldUseNarrowLayout} = useResponsiveLayout(); - - // For small screen, we don't call openReport API when we go to a sub report page by deeplink - // So we need to call openReport here for small screen - useEffect(() => { - if (!shouldUseNarrowLayout || (!isEmptyObject(report) && !isEmptyObject(linkedReportAction))) { - return; - } - openReport({reportID: props.route.params.reportID, introSelected, betas}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldUseNarrowLayout, props.route.params.reportID]); - - // Perform all the loading checks - const isLoadingReport = isLoadingReportData && !report?.reportID; - const isLoadingReportAction = isEmptyObject(reportActions) || (reportLoadingState?.isLoadingInitialReportActions && isEmptyObject(linkedReportAction)); - const isReportArchived = useReportIsArchived(report?.reportID); - const shouldHideReport = !isLoadingReport && (!report?.reportID || !canAccessReport(report, betas, isReportArchived)); - - if ((isLoadingReport || isLoadingReportAction) && !shouldHideReport) { - const reasonAttributes: SkeletonSpanReasonAttributes = { - context: 'withReportAndReportActionOrNotFound', - isLoadingReport, - isLoadingReportAction, - }; - return ; - } +type WithReportOrNotFoundImplProps = { + WrappedComponent: ComponentType; +} & TProps; + +function WithReportOrNotFoundImpl({WrappedComponent, ...props}: WithReportOrNotFoundImplProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.route.params.reportID}`); + + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); + const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${props.route.params.reportID}`); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${props.route.params.reportID}`); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + + const parentReportAction = useParentReportAction(report); + let linkedReportAction: OnyxEntry = reportActions?.[`${props.route.params.reportActionID}`]; + + // Handle threads if needed + if (!linkedReportAction?.reportActionID) { + linkedReportAction = parentReportAction ?? undefined; + } - // Perform the access/not found checks - // Be sure to avoid showing the not-found page while the parent report actions are still being read from Onyx. The parentReportAction will be undefined while it's being read from Onyx - // and then linkedReportAction will either be a valid parentReportAction or an empty object. In the case of an empty object, then it's OK to show the not-found page. - if (shouldHideReport || (parentReportAction !== undefined && isEmptyObject(linkedReportAction))) { - return ; + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + // For small screen, we don't call openReport API when we go to a sub report page by deeplink + // So we need to call openReport here for small screen + useEffect(() => { + if (!shouldUseNarrowLayout || (!isEmptyObject(report) && !isEmptyObject(linkedReportAction))) { + return; } + openReport({reportID: props.route.params.reportID, introSelected, betas}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldUseNarrowLayout, props.route.params.reportID]); + + // Perform all the loading checks + const isLoadingReport = isLoadingReportData && !report?.reportID; + const isLoadingReportAction = isEmptyObject(reportActions) || (reportLoadingState?.isLoadingInitialReportActions && isEmptyObject(linkedReportAction)); + const isReportArchived = useReportIsArchived(report?.reportID); + const shouldHideReport = !isLoadingReport && (!report?.reportID || !canAccessReport(report, betas, isReportArchived)); + + if ((isLoadingReport || isLoadingReportAction) && !shouldHideReport) { + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'withReportAndReportActionOrNotFound', + isLoadingReport, + isLoadingReportAction, + }; + return ; + } + + // Perform the access/not found checks + // Be sure to avoid showing the not-found page while the parent report actions are still being read from Onyx. The parentReportAction will be undefined while it's being read from Onyx + // and then linkedReportAction will either be a valid parentReportAction or an empty object. In the case of an empty object, then it's OK to show the not-found page. + if (shouldHideReport || (parentReportAction !== undefined && isEmptyObject(linkedReportAction))) { + return ; + } + return ( + + ); +} + +export default function (WrappedComponent: ComponentType): ComponentType { + function WithReportOrNotFound(props: TProps) { return ( - ); } WithReportOrNotFound.displayName = `withReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - return React.memo(WithReportOrNotFound); + return WithReportOrNotFound; } export type {WithReportAndReportActionOrNotFoundProps}; diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index b35aa2707361..c092db1a6e6f 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -68,53 +68,72 @@ type MoneyRequestRouteName = type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & PlatformStackScreenProps; -export default function >( - WrappedComponent: ComponentType, - shouldShowLoadingIndicator = false, -): React.ComponentType> { - function WithFullTransactionOrNotFound(props: Omit) { - const {route} = props; - const transactionID = route.params.transactionID; - const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; +type WithFullTransactionOrNotFoundImplProps> = { + WrappedComponent: ComponentType; + shouldShowLoadingIndicator: boolean; +} & Omit; + +function WithFullTransactionOrNotFoundImpl>({ + WrappedComponent, + shouldShowLoadingIndicator, + ...props +}: WithFullTransactionOrNotFoundImplProps) { + const {route} = props; + const transactionID = route.params.transactionID; + const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; - const [transaction, transactionResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - const [transactionDraft, transactionDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${getNonEmptyStringOnyxID(transactionID)}`); - const isLoadingTransaction = isLoadingOnyxValue(transactionResult, transactionDraftResult); + const [transaction, transactionResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + const [transactionDraft, transactionDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${getNonEmptyStringOnyxID(transactionID)}`); + const isLoadingTransaction = isLoadingOnyxValue(transactionResult, transactionDraftResult); - const [splitTransactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${getNonEmptyStringOnyxID(transactionID)}`); + const [splitTransactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${getNonEmptyStringOnyxID(transactionID)}`); - const userType = 'iouType' in route.params && route.params.iouType ? route.params.iouType : CONST.IOU.TYPE.CREATE; + const userType = 'iouType' in route.params && route.params.iouType ? route.params.iouType : CONST.IOU.TYPE.CREATE; - const isFocused = useIsFocused(); + const isFocused = useIsFocused(); - const transactionDraftData = userType === CONST.IOU.TYPE.SPLIT_EXPENSE ? splitTransactionDraft : transactionDraft; + const transactionDraftData = userType === CONST.IOU.TYPE.SPLIT_EXPENSE ? splitTransactionDraft : transactionDraft; - // If the transaction does not have a transactionID, then the transaction no longer exists in Onyx as a full transaction and the not-found page should be shown. - // In addition, the not-found page should be shown only if the component screen's route is active (i.e. is focused). - // This is to prevent it from showing when the modal is being dismissed while navigating to a different route (e.g. on requesting money). - if (!transactionID) { - return ; - } + // If the transaction does not have a transactionID, then the transaction no longer exists in Onyx as a full transaction and the not-found page should be shown. + // In addition, the not-found page should be shown only if the component screen's route is active (i.e. is focused). + // This is to prevent it from showing when the modal is being dismissed while navigating to a different route (e.g. on requesting money). + if (!transactionID) { + return ; + } - if (isLoadingTransaction && shouldShowLoadingIndicator) { - const reasonAttributes: SkeletonSpanReasonAttributes = { - context: 'withFullTransactionOrNotFound', - isLoadingTransaction, - }; - return ; - } + if (isLoadingTransaction && shouldShowLoadingIndicator) { + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'withFullTransactionOrNotFound', + isLoadingTransaction, + }; + return ; + } + return ( + + ); +} + +export default function >( + WrappedComponent: ComponentType, + shouldShowLoadingIndicator = false, +): React.ComponentType> { + function WithFullTransactionOrNotFound(props: Omit) { return ( - ); } WithFullTransactionOrNotFound.displayName = `withFullTransactionOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - return React.memo(WithFullTransactionOrNotFound); + return WithFullTransactionOrNotFound; } export type {WithFullTransactionOrNotFoundProps}; diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 32680c736613..71ddd975c102 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -73,60 +73,79 @@ type MoneyRequestRouteName = type WithWritableReportOrNotFoundProps = WithWritableReportOrNotFoundOnyxProps & PlatformStackScreenProps; +type WithWritableReportOrNotFoundImplProps> = { + WrappedComponent: ComponentType; + shouldIncludeDeprecatedIOUType: boolean; +} & Omit; + function dismissMoneyRequestModal() { Navigation.dismissModal(); } +function WithWritableReportOrNotFoundImpl>({ + WrappedComponent, + shouldIncludeDeprecatedIOUType, + ...props +}: WithWritableReportOrNotFoundImplProps) { + const {route} = props; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`); + const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${route.params.reportID}`); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const isReportArchived = useReportIsArchived(report?.reportID); + + const iouTypeParamIsInvalid = !Object.values(CONST.IOU.TYPE) + .filter((type) => shouldIncludeDeprecatedIOUType || (type !== CONST.IOU.TYPE.REQUEST && type !== CONST.IOU.TYPE.SEND)) + .includes(route.params?.iouType); + const isEditing = 'action' in route.params && route.params?.action === CONST.IOU.ACTION.EDIT; + + useEffect(() => { + if (!!report?.reportID || !route.params.reportID || !!reportDraft || !isEditing) { + return; + } + openReport({reportID: route.params.reportID, introSelected, betas}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (isEditing && isLoadingApp) { + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'withWritableReportOrNotFound', + isLoadingApp, + }; + return ; + } + + if (iouTypeParamIsInvalid || !canUserPerformWriteAction(report ?? {reportID: ''}, isReportArchived)) { + return ; + } + + return ( + + ); +} + export default function >( WrappedComponent: ComponentType, shouldIncludeDeprecatedIOUType = false, ): React.ComponentType> { function WithWritableReportOrNotFound(props: Omit) { - const {route} = props; - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`); - const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${route.params.reportID}`); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const isReportArchived = useReportIsArchived(report?.reportID); - - const iouTypeParamIsInvalid = !Object.values(CONST.IOU.TYPE) - .filter((type) => shouldIncludeDeprecatedIOUType || (type !== CONST.IOU.TYPE.REQUEST && type !== CONST.IOU.TYPE.SEND)) - .includes(route.params?.iouType); - const isEditing = 'action' in route.params && route.params?.action === CONST.IOU.ACTION.EDIT; - - useEffect(() => { - if (!!report?.reportID || !route.params.reportID || !!reportDraft || !isEditing) { - return; - } - openReport({reportID: route.params.reportID, introSelected, betas}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (isEditing && isLoadingApp) { - const reasonAttributes: SkeletonSpanReasonAttributes = { - context: 'withWritableReportOrNotFound', - isLoadingApp, - }; - return ; - } - - if (iouTypeParamIsInvalid || !canUserPerformWriteAction(report ?? {reportID: ''}, isReportArchived)) { - return ; - } - return ( - ); } WithWritableReportOrNotFound.displayName = `withWritableReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - return React.memo(WithWritableReportOrNotFound); + return WithWritableReportOrNotFound; } export type {WithWritableReportOrNotFoundProps}; diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index a5d0c9928f9f..60d3e645ba08 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -88,36 +88,49 @@ const policyDefaultProps: WithPolicyOnyxProps = { isLoadingPolicy: false, }; +type WithPolicyImplProps = { + WrappedComponent: ComponentType; +} & Omit; + +function WithPolicyImpl({WrappedComponent, ...props}: WithPolicyImplProps) { + const policyID = getPolicyIDFromRoute(props.route as PolicyRoute); + const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); + const [policy, policyResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyDraft, policyDraftResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`); + + const isLoadingPolicy = !hasLoadedApp || (!!policyID && isLoadingOnyxValue(policyResults, policyDraftResults)); + + useEffect(() => { + if (!policyID) { + return; + } + updateLastAccessedWorkspace(policyID); + }, [policyID]); + + return ( + + ); +} + /* * HOC for connecting a policy in Onyx corresponding to the policyID in route params */ export default function (WrappedComponent: ComponentType): React.ComponentType> { function WithPolicy(props: Omit) { - const policyID = getPolicyIDFromRoute(props.route as PolicyRoute); - const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); - const [policy, policyResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const [policyDraft, policyDraftResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`); - - const isLoadingPolicy = !hasLoadedApp || (!!policyID && isLoadingOnyxValue(policyResults, policyDraftResults)); - - useEffect(() => { - if (!policyID) { - return; - } - updateLastAccessedWorkspace(policyID); - }, [policyID]); - return ( - ); } - return React.memo(WithPolicy); + return WithPolicy; } export {policyDefaultProps}; diff --git a/src/pages/workspace/withPolicyAndFullscreenLoading.tsx b/src/pages/workspace/withPolicyAndFullscreenLoading.tsx index 0e085076e62c..141d1eb0071a 100644 --- a/src/pages/workspace/withPolicyAndFullscreenLoading.tsx +++ b/src/pages/workspace/withPolicyAndFullscreenLoading.tsx @@ -31,41 +31,53 @@ type ComponentWithPolicyAndFullscreenLoading, keyof WithPolicyOnyxProps> >; +type WithPolicyAndFullscreenLoadingImplProps = { + WrappedComponent: ComponentType; +} & Omit; + +function WithPolicyAndFullscreenLoadingImpl({ + WrappedComponent, + policy = policyDefaultProps.policy, + policyDraft = policyDefaultProps.policyDraft, + isLoadingPolicy = policyDefaultProps.isLoadingPolicy, + ...rest +}: WithPolicyAndFullscreenLoadingImplProps) { + const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + + if ((isLoadingPolicy || isLoadingReportData) && isEmpty(policy) && isEmpty(policyDraft)) { + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'withPolicyAndFullscreenLoading', + isLoadingPolicy: !!isLoadingPolicy, + isLoadingReportData: !!isLoadingReportData, + }; + return ; + } + + return ( + + ); +} + export default function withPolicyAndFullscreenLoading( WrappedComponent: ComponentType, ): ComponentWithPolicyAndFullscreenLoading { - function WithPolicyAndFullscreenLoading({ - policy = policyDefaultProps.policy, - policyDraft = policyDefaultProps.policyDraft, - isLoadingPolicy = policyDefaultProps.isLoadingPolicy, - ...rest - }: Omit) { - const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - - if ((isLoadingPolicy || isLoadingReportData) && isEmpty(policy) && isEmpty(policyDraft)) { - const reasonAttributes: SkeletonSpanReasonAttributes = { - context: 'withPolicyAndFullscreenLoading', - isLoadingPolicy: !!isLoadingPolicy, - isLoadingReportData: !!isLoadingReportData, - }; - return ; - } - + function WithPolicyAndFullscreenLoading(props: Omit) { return ( - ); } - // OXC's React Compiler does not memoize this component on web; memoize it before wrapping so it is - // memoized on both platforms. - return withPolicy(React.memo(WithPolicyAndFullscreenLoading)); + return withPolicy(WithPolicyAndFullscreenLoading); } export type {WithPolicyAndFullscreenLoadingProps}; diff --git a/src/pages/workspace/withPolicyConnections.tsx b/src/pages/workspace/withPolicyConnections.tsx index 068ea403e89b..9ffc1c3fbf1c 100644 --- a/src/pages/workspace/withPolicyConnections.tsx +++ b/src/pages/workspace/withPolicyConnections.tsx @@ -23,6 +23,11 @@ type WithPolicyConnectionsProps = WithPolicyProps & { isConnectionDataFetchNeeded: boolean; }; +type WithPolicyConnectionsImplProps = { + WrappedComponent: ComponentType; + shouldBlockView: boolean; +} & TProps; + /** * Higher-order component that fetches the connections data and populates * the corresponding field of the policy object if the field is empty. It then passes the policy object @@ -33,43 +38,51 @@ type WithPolicyConnectionsProps = WithPolicyProps & { * Only the active policy gets the complete policy data upon app start that includes the connections data. * For other policies, the connections data needs to be fetched when it's needed. */ -function withPolicyConnections(WrappedComponent: ComponentType, shouldBlockView = true) { - function WithPolicyConnections(props: TProps) { - const {isOffline} = useNetwork(); - const [hasConnectionsDataBeenFetched, hasConnectionsDataBeenFetchedResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${props.policy?.id}`); - const isOnyxDataLoading = isLoadingOnyxValue(hasConnectionsDataBeenFetchedResult); - const isConnectionDataFetchNeeded = - !isOnyxDataLoading && !isOffline && !!props.policy && (!!props.policy.areConnectionsEnabled || !isEmptyObject(props.policy.connections)) && !hasConnectionsDataBeenFetched; - - const isFetchingData = isConnectionDataFetchNeeded && !!props.policy?.id && !isBoolean(hasConnectionsDataBeenFetched); - - useEffect(() => { - if (!isConnectionDataFetchNeeded || !props.policy?.id) { - return; - } - openPolicyAccountingPage(props.policy.id); - }, [props.policy?.id, isConnectionDataFetchNeeded]); - - if ((isFetchingData || isOnyxDataLoading) && shouldBlockView) { - const reasonAttributes: SkeletonSpanReasonAttributes = { - context: 'withPolicyConnections', - isFetchingData, - isOnyxDataLoading, - }; - return ; +function WithPolicyConnectionsImpl({WrappedComponent, shouldBlockView, ...props}: WithPolicyConnectionsImplProps) { + const {isOffline} = useNetwork(); + const [hasConnectionsDataBeenFetched, hasConnectionsDataBeenFetchedResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${props.policy?.id}`); + const isOnyxDataLoading = isLoadingOnyxValue(hasConnectionsDataBeenFetchedResult); + const isConnectionDataFetchNeeded = + !isOnyxDataLoading && !isOffline && !!props.policy && (!!props.policy.areConnectionsEnabled || !isEmptyObject(props.policy.connections)) && !hasConnectionsDataBeenFetched; + + const isFetchingData = isConnectionDataFetchNeeded && !!props.policy?.id && !isBoolean(hasConnectionsDataBeenFetched); + + useEffect(() => { + if (!isConnectionDataFetchNeeded || !props.policy?.id) { + return; } + openPolicyAccountingPage(props.policy.id); + }, [props.policy?.id, isConnectionDataFetchNeeded]); + + if ((isFetchingData || isOnyxDataLoading) && shouldBlockView) { + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'withPolicyConnections', + isFetchingData, + isOnyxDataLoading, + }; + return ; + } + return ( + + ); +} + +function withPolicyConnections(WrappedComponent: ComponentType, shouldBlockView = true) { + function WithPolicyConnections(props: TProps) { return ( - ); } - // OXC's React Compiler does not memoize this component on web; memoize it before wrapping so it is - // memoized on both platforms. - return withPolicy(React.memo(WithPolicyConnections)); + return withPolicy(WithPolicyConnections); } export default withPolicyConnections; From 6dc63e36d3080040afe09455f7d113de44ca118e Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 13:21:45 -0700 Subject: [PATCH 07/11] chore: seatbelt OXC Impl unsafe casts Fix easy lint nits; widen no-unsafe-type-assertion baseline for unavoidable casts from generic/unknown Impl splits. Co-authored-by: Cursor --- config/eslint/eslint.seatbelt.tsv | 32 +++++++++++++------ .../AnimatedFlatListWithCellRenderer.tsx | 1 - .../FlatList/hooks/useFlatListScrollKey.ts | 3 +- .../SubStepForms/PushRowFieldsStep.tsx | 2 +- src/hooks/useStepFormSubmit.ts | 2 +- .../index.native.tsx | 4 +-- .../index.tsx | 4 +-- .../withReportAndPrivateNotesOrNotFound.tsx | 15 +++++---- 8 files changed, 38 insertions(+), 25 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index deccb27ae793..c67cc5136256 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -80,8 +80,7 @@ "../../src/components/AnchorForAttachmentsOnly/index.tsx" "no-restricted-syntax" 1 "../../src/components/AnchorForCommentsOnly/index.tsx" "no-restricted-syntax" 1 "../../src/components/AnimatedFlatListWithCellRenderer.tsx" "@typescript-eslint/no-deprecated/Animated.createAnimatedComponent" 1 -"../../src/components/AnimatedFlatListWithCellRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/AnimatedFlatListWithCellRenderer.tsx" "react-hooks/refs" 2 +"../../src/components/AnimatedFlatListWithCellRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/AnimatedSubmitButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AnimatedSubmitButton/index.tsx" "no-restricted-imports" 1 "../../src/components/AnimatedSubmitButton/index.tsx" "react-hooks/refs" 6 @@ -101,6 +100,7 @@ "../../src/components/Attachments/AttachmentView/index.tsx" "no-restricted-imports" 1 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "no-restricted-syntax" 1 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 +"../../src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AutoCompleteSuggestions/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/AutoSubmitModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Avatar.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -179,7 +179,7 @@ "../../src/components/FlatList/FlatList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/FlatList/FlatList/index.tsx" "react-hooks/refs" 1 "../../src/components/FlatList/hooks/useFlatListHandle.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/components/FlatList/hooks/useFlatListScrollKey.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/FlatList/hooks/useFlatListScrollKey.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/FloatingGPSButton/index.native.tsx" "no-restricted-imports" 1 "../../src/components/FocusModeNotification.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -322,7 +322,7 @@ "../../src/components/PDFView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PDFView/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ParentNavigationSubtitle.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Picker/BasePicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/Picker/BasePicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Picker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PlanTypeSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PopoverMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -379,6 +379,7 @@ "../../src/components/Search/FilterComponents/DateFilterBase.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Search/FilterComponents/DatePresetFilterBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/FilterComponents/ReportField/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Search/FilterComponents/SingleSelect.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterComponents/TypeSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterDropdowns/ActionButtons.tsx" "no-restricted-imports" 1 "../../src/components/Search/FilterDropdowns/CardSelectPopup.tsx" "react-hooks/set-state-in-effect" 1 @@ -443,13 +444,14 @@ "../../src/components/Search/index.tsx" "react-hooks/refs" 5 "../../src/components/Search/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/SelectionButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/SelectionList/BaseSelectionList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/BaseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/SpendRuleListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/components/Footer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/components/Footer.tsx" "no-restricted-imports" 1 -"../../src/components/SelectionList/hooks/useFlattenedSections.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/SelectionList/hooks/useFlattenedSections.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SelectionList/utils/getListboxRole/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SettlementButton/AnimatedSettlementButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SettlementButton/AnimatedSettlementButton.tsx" "no-restricted-imports" 1 @@ -466,7 +468,7 @@ "../../src/components/StatePicker/StateSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/StatePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/StatusBadge.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/SubStepForms/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/SubStepForms/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SubStepForms/AgreementsFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/components/SubStepForms/ConfirmationStep.tsx" "no-restricted-imports" 1 "../../src/components/SubStepForms/CountryFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -474,7 +476,7 @@ "../../src/components/SubStepForms/DocusignFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SubStepForms/DocusignFullStep.tsx" "no-restricted-imports" 1 "../../src/components/SubStepForms/FullNameStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/components/SubStepForms/PushRowFieldsStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/SubStepForms/PushRowFieldsStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SubStepForms/RegistrationNumberStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SwipeableView/index.native.tsx" "react-hooks/refs" 2 "../../src/components/SymbolButton.tsx" "no-restricted-syntax" 1 @@ -482,6 +484,7 @@ "../../src/components/Table/Table.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Table/TableContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Table/middlewares/filtering.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Tables/WorkspaceCategoryRulesTable/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx" "no-restricted-imports" 1 "../../src/components/Tables/WorkspaceCompanyCardsTable/index.tsx" "no-restricted-imports" 1 @@ -524,6 +527,7 @@ "../../src/components/Tooltip/PopoverAnchorTooltip.tsx" "react-hooks/refs" 7 "../../src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/components/TransactionItemRow/DataCells/MerchantCell.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/UpdateAppModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/UploadFile.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx" "no-restricted-imports" 1 @@ -539,7 +543,8 @@ "../../src/components/ZeroWidthView/index.tsx" "no-restricted-syntax" 2 "../../src/components/createOnyxContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/withCurrentUserPersonalDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/withNavigationFallback.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/withNavigationFallback.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/components/withNavigationTransitionEnd.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withToggleVisibilityView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withViewportOffsetTop.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useAccountIndicatorChecks.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -581,6 +586,7 @@ "../../src/hooks/useHtmlPaste/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useImportSpreadsheetConfirmModal.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useInitial.ts" "react-hooks/refs" 4 +"../../src/hooks/useInitialSelection.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useIsBlockedToAddFeed.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useIsOwnWorkspaceChatRef.ts" "react-hooks/refs" 2 "../../src/hooks/useIsPaidPolicyAdmin.ts" "no-restricted-imports" 1 @@ -636,9 +642,9 @@ "../../src/hooks/useSingleExecution/index.native.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/hooks/useSingleExecution/index.native.ts" "react-hooks/refs" 1 "../../src/hooks/useSplitEffectivePolicy.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 -"../../src/hooks/useStepFormSubmit.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/hooks/useStableIndexedHandler.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useStepFormSubmit.ts" "no-restricted-syntax" 1 -"../../src/hooks/useSubPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/hooks/useSubPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useSubStep/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useSubStep/index.ts" "react-hooks/refs" 2 "../../src/hooks/useTackInputFocus/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -652,6 +658,7 @@ "../../src/hooks/useTripTransactions.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/hooks/useViolations.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useWindowDimensions/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/hooks/useWorkletStateMachine/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/languages/flattenObject.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/API/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Accessibility/moveAccessibilityFocus/types.ts" "@typescript-eslint/no-deprecated/ElementRef" 1 @@ -739,6 +746,8 @@ "../../src/libs/Navigation/OnyxTabNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/libs/Navigation/guards/AIFeaturesPromoGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/guards/MigratedUserWelcomeModalGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1304,7 +1313,9 @@ "../../src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/pages/inbox/report/shouldUseEmojiPickerSelection/index.web.ts" "no-restricted-syntax" 1 "../../src/pages/inbox/report/useActiveDraftReportAction.ts" "rulesdir/no-useOnyx-dependencies-arg" 2 +"../../src/pages/inbox/report/useDebouncedSaveDraft.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportOrNotFound.tsx" "react-hooks/refs" 3 "../../src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1714,6 +1725,7 @@ "../../src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/withPolicy.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/withPolicyAndFullscreenLoading.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/withPolicyConnections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 diff --git a/src/components/AnimatedFlatListWithCellRenderer.tsx b/src/components/AnimatedFlatListWithCellRenderer.tsx index e63af64a451d..6a37ec2b31d0 100644 --- a/src/components/AnimatedFlatListWithCellRenderer.tsx +++ b/src/components/AnimatedFlatListWithCellRenderer.tsx @@ -69,7 +69,6 @@ type AnimatedFlatListWithCellRendererProps = Omit) { const {itemLayoutAnimation, skipEnteringExitingAnimations, ref, CellRendererComponent: outerCellRenderer, ...restProps} = props; diff --git a/src/components/FlatList/hooks/useFlatListScrollKey.ts b/src/components/FlatList/hooks/useFlatListScrollKey.ts index 3900225a7bdf..65cbd782b509 100644 --- a/src/components/FlatList/hooks/useFlatListScrollKey.ts +++ b/src/components/FlatList/hooks/useFlatListScrollKey.ts @@ -9,8 +9,7 @@ import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; -import type {ForwardedRef} from 'react'; -import type {RefObject} from 'react'; +import type {ForwardedRef, RefObject} from 'react'; import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import {createElement, useEffect, useMemo, useRef, useState} from 'react'; diff --git a/src/components/SubStepForms/PushRowFieldsStep.tsx b/src/components/SubStepForms/PushRowFieldsStep.tsx index 0dd4531ebf20..0d20459fb589 100644 --- a/src/components/SubStepForms/PushRowFieldsStep.tsx +++ b/src/components/SubStepForms/PushRowFieldsStep.tsx @@ -46,7 +46,7 @@ type PushRowFieldWidened = { }; type PushRowFieldsStepPropsWidened = Omit, 'pushRowFields'> & { - pushRowFields: Array; + pushRowFields: PushRowFieldWidened[]; }; /** diff --git a/src/hooks/useStepFormSubmit.ts b/src/hooks/useStepFormSubmit.ts index 57a5906ec215..7fc839ba30d0 100644 --- a/src/hooks/useStepFormSubmit.ts +++ b/src/hooks/useStepFormSubmit.ts @@ -8,7 +8,7 @@ import type {SubStepProps} from './useSubStep/types'; type UseStepFormSubmitParams = Pick & { formId: OnyxFormKey; - fieldIds: readonly (string | number | symbol)[]; + fieldIds: ReadonlyArray; shouldSaveDraft: boolean; }; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx index 6e95722263ca..73a34660d98c 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx @@ -132,12 +132,12 @@ function createPlatformStackNavigatorComponent, ) { const PlatformNavigatorImpl = createPlatformNavigatorImpl({ - createRouter: (options?.createRouter ?? StackRouter) as NonNullable['createRouter']>, + createRouter: options?.createRouter ?? StackRouter, useCustomState: (options?.useCustomState ?? (() => undefined)) as NonNullable['useCustomState']>, defaultScreenOptions: options?.defaultScreenOptions, ExtraContent: options?.ExtraContent, NavigationContentWrapper: options?.NavigationContentWrapper, - useCustomEffects: (options?.useCustomEffects ?? (() => undefined)) as NonNullable['useCustomEffects']>, + useCustomEffects: options?.useCustomEffects ?? (() => undefined), freezeNonTopScreens: options?.freezeNonTopScreens, displayName, }); diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index 76c9f1cc0ea1..76e316b461da 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -146,12 +146,12 @@ function createPlatformStackNavigatorComponent, ) { const PlatformNavigatorImpl = createPlatformNavigatorImpl({ - createRouter: (options?.createRouter ?? StackRouter) as NonNullable['createRouter']>, + createRouter: options?.createRouter ?? StackRouter, useCustomState: (options?.useCustomState ?? (() => undefined)) as NonNullable['useCustomState']>, defaultScreenOptions: options?.defaultScreenOptions, ExtraContent: options?.ExtraContent, NavigationContentWrapper: options?.NavigationContentWrapper, - useCustomEffects: (options?.useCustomEffects ?? (() => undefined)) as NonNullable['useCustomEffects']>, + useCustomEffects: options?.useCustomEffects ?? (() => undefined), freezeNonTopScreens: options?.freezeNonTopScreens, displayName, }); diff --git a/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx b/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx index cbe955ab5f83..5abda6b6786c 100644 --- a/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx +++ b/src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx @@ -69,12 +69,15 @@ function WithReportAndPrivateNotesOrNotFoundImpl Date: Wed, 15 Jul 2026 13:32:11 -0700 Subject: [PATCH 08/11] fix: restore worklet SM payload types + lazy asset test 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 --- src/hooks/useWorkletStateMachine/index.ts | 6 +++++- tests/unit/hooks/useLazyAsset.test.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/hooks/useWorkletStateMachine/index.ts b/src/hooks/useWorkletStateMachine/index.ts index 43925e03df1a..1292b06757d0 100644 --- a/src/hooks/useWorkletStateMachine/index.ts +++ b/src/hooks/useWorkletStateMachine/index.ts @@ -1,5 +1,7 @@ import Log from '@libs/Log'; +import type {SharedValue} from 'react-native-reanimated'; + import {fastMerge} from 'expensify-common'; import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; @@ -163,9 +165,11 @@ function useWorkletStateMachineImpl(stateMachine: StateMachine, initialState: St * @returns an object containing the current state, a transition function, and a reset function */ function useWorkletStateMachine, P>(stateMachine: SM, initialState: State

) { - return useWorkletStateMachineImpl(stateMachine, initialState as State) as ReturnType & { + return useWorkletStateMachineImpl(stateMachine, initialState as State) as { + currentState: SharedValue>; transitionWorklet: (action: ActionWithPayload

) => void; transition: (action: ActionWithPayload

) => void; + reset: () => void; }; } diff --git a/tests/unit/hooks/useLazyAsset.test.ts b/tests/unit/hooks/useLazyAsset.test.ts index df6fdae87a03..8d10291869c1 100644 --- a/tests/unit/hooks/useLazyAsset.test.ts +++ b/tests/unit/hooks/useLazyAsset.test.ts @@ -289,7 +289,7 @@ describe('useMemoizedLazyAsset', () => { }); }); - it('should handle function reference changes', async () => { + it('should keep the initial importFn stable across rerenders', async () => { const importFn1 = jest.fn(() => Promise.resolve({default: mockAsset})); const importFn2 = jest.fn(() => Promise.resolve({default: mockFallbackAsset})); @@ -306,13 +306,15 @@ describe('useMemoizedLazyAsset', () => { expect(result.current.asset).toBe(mockAsset); }); - // Change to different function + // Callers pass inline loaders; rebinding every render would loop setState, so + // useMemoizedLazyAsset captures the first importFn only. rerender({importFn: importFn2}); - // Wait for new import function to be called await waitFor(() => { - expect(importFn2).toHaveBeenCalled(); + expect(result.current.asset).toBe(mockAsset); }); + expect(importFn2).not.toHaveBeenCalled(); + expect(importFn1).toHaveBeenCalled(); }); it('returns PlaceholderIcon while loading', () => { From a40ad072423da3b68750d24e97e2485d9a768147 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 13:56:13 -0700 Subject: [PATCH 09/11] fix: dual-compile platform stack navigators Hoist PlatformNavigatorImpl to module scope and replace dynamic hook options with getCustomState + Effects so OXC can auto-memoize. Co-authored-by: Cursor --- .../createRightModalNavigator/index.tsx | 7 +- .../createRootStackNavigator/index.tsx | 19 +- .../createSearchFullscreenNavigator/index.tsx | 11 +- .../useCustomState/index.ts | 4 +- .../createSplitNavigator/index.tsx | 15 +- .../createWorkspaceNavigator/index.tsx | 7 +- .../index.native.tsx | 207 ++++++++-------- .../index.tsx | 234 +++++++++--------- .../types/NavigatorComponent.ts | 20 +- 9 files changed, 262 insertions(+), 262 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx index efc8964b62e9..56e45cd6357f 100644 --- a/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx @@ -11,14 +11,17 @@ import {createNavigatorFactory} from '@react-navigation/native'; import RightModalRouter from './RightModalRouter'; -function useCustomEffects(props: CustomEffectsHookProps) { +function RightModalNavigatorEffects(props: CustomEffectsHookProps) { usePreserveNavigatorState(props.state, props.parentRoute); + // Returning null makes Babel skip memoization for this Effects slot; an empty fragment is required. + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; } const RightModalNavigatorComponent = createPlatformStackNavigatorComponent(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, { createRouter: RightModalRouter, defaultScreenOptions: defaultPlatformStackScreenOptions, - useCustomEffects, + Effects: RightModalNavigatorEffects, }); function createRightModalNavigator< diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx index b64ca63d6794..2f255b1fddee 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx @@ -4,7 +4,13 @@ import addRootHistoryRouterExtension from '@libs/Navigation/AppNavigator/routerE import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange'; import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; -import type {PlatformStackNavigationEventMap, PlatformStackNavigationOptions, PlatformStackNavigationState, PlatformStackRouterFactory} from '@libs/Navigation/PlatformStackNavigation/types'; +import type { + CustomEffectsHookProps, + PlatformStackNavigationEventMap, + PlatformStackNavigationOptions, + PlatformStackNavigationState, + PlatformStackRouterFactory, +} from '@libs/Navigation/PlatformStackNavigation/types'; import type {NavigationProp, NavigatorTypeBagBase, ParamListBase, StaticConfig, TypedNavigator} from '@react-navigation/native'; @@ -13,11 +19,18 @@ import {createNavigatorFactory} from '@react-navigation/native'; import RootStackRouter from './RootStackRouter'; import useCustomRootStackNavigatorState from './useCustomRootStackNavigatorState'; +function RootStackNavigatorEffects(props: CustomEffectsHookProps) { + useNavigationResetOnLayoutChange(props); + // Returning null makes Babel skip memoization for this Effects slot; an empty fragment is required. + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + const RootStackNavigatorComponent = createPlatformStackNavigatorComponent('RootStackNavigator', { createRouter: addRootHistoryRouterExtension(RootStackRouter as PlatformStackRouterFactory), defaultScreenOptions: defaultPlatformStackScreenOptions, - useCustomEffects: useNavigationResetOnLayoutChange, - useCustomState: useCustomRootStackNavigatorState, + Effects: RootStackNavigatorEffects, + getCustomState: useCustomRootStackNavigatorState, ExtraContent: RootNavigatorExtraContent, }); diff --git a/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx index 39cd4104bef3..53955e896e89 100644 --- a/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx @@ -13,18 +13,21 @@ import type {NavigationProp, NavigatorTypeBagBase, ParamListBase, StaticConfig, import {createNavigatorFactory} from '@react-navigation/native'; import SearchFullscreenRouter from './SearchFullscreenRouter'; -import useCustomState from './useCustomState'; +import getCustomState from './useCustomState'; -function useCustomEffects(props: CustomEffectsHookProps) { +function SearchFullscreenNavigatorEffects(props: CustomEffectsHookProps) { useNavigationResetOnLayoutChange(props); usePreserveNavigatorState(props.state, props.parentRoute); + // Returning null makes Babel skip memoization for this Effects slot; an empty fragment is required. + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; } const SearchFullscreenNavigatorComponent = createPlatformStackNavigatorComponent('SearchFullscreenNavigator', { createRouter: addPushParamsRouterExtension(SearchFullscreenRouter), defaultScreenOptions: defaultPlatformStackScreenOptions, - useCustomEffects, - useCustomState, + Effects: SearchFullscreenNavigatorEffects, + getCustomState, ExtraContent: SearchSidebar, }); diff --git a/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/useCustomState/index.ts b/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/useCustomState/index.ts index ff3aec2a0ddb..3f4686fdc999 100644 --- a/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/useCustomState/index.ts +++ b/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/useCustomState/index.ts @@ -3,10 +3,10 @@ import type {CustomStateHookProps} from '@libs/Navigation/PlatformStackNavigatio import SCREENS from '@src/SCREENS'; /** - * This is a custom state hook for SearchFullscreenNavigator that is used to render the last two search routes in the stack. + * Transforms SearchFullscreenNavigator state to render from the last SEARCH.ROOT onward. * @see SearchFullscreenNavigator use only! */ -export default function useCustomState({state}: CustomStateHookProps) { +export default function getCustomState({state}: CustomStateHookProps) { const lastSearchNavigatorIndex = state.routes.findLastIndex((route) => route.name === SCREENS.SEARCH.ROOT); const routesToRender = state.routes.slice(Math.max(0, lastSearchNavigatorIndex), state.routes.length); return {...state, routes: routesToRender, index: routesToRender.length - 1}; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx index 806151770e18..a249fb9389d9 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx @@ -1,5 +1,3 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange'; import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; @@ -19,14 +17,15 @@ import SidebarSpacerWrapper from './SidebarSpacerWrapper'; import SplitRouter from './SplitRouter'; import usePreserveNavigatorState from './usePreserveNavigatorState'; -function useCustomEffects(props: CustomEffectsHookProps) { +function SplitNavigatorEffects(props: CustomEffectsHookProps) { useNavigationResetOnLayoutChange(props); usePreserveNavigatorState(props.state, props.parentRoute); + // Returning null makes Babel skip memoization for this Effects slot; an empty fragment is required. + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; } -function useCustomSplitNavigatorState({state}: CustomStateHookProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - +function getCustomSplitNavigatorState({state, shouldUseNarrowLayout}: CustomStateHookProps) { const sidebarScreenRoute = state.routes.at(0); if (!sidebarScreenRoute) { @@ -41,9 +40,9 @@ function useCustomSplitNavigatorState({state}: CustomStateHookProps) { const SplitNavigatorComponent = createPlatformStackNavigatorComponent('SplitNavigator', { createRouter: SplitRouter, - useCustomEffects, + Effects: SplitNavigatorEffects, defaultScreenOptions: defaultPlatformStackScreenOptions, - useCustomState: useCustomSplitNavigatorState, + getCustomState: getCustomSplitNavigatorState, NavigationContentWrapper: SidebarSpacerWrapper, freezeNonTopScreens: true, }); diff --git a/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/index.tsx index 2c5f1c3e7389..388f3400b39c 100644 --- a/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/index.tsx @@ -12,15 +12,18 @@ import {createNavigatorFactory} from '@react-navigation/native'; import WorkspaceRouter from './WorkspaceRouter'; -function useCustomEffects(props: CustomEffectsHookProps) { +function WorkspaceNavigatorEffects(props: CustomEffectsHookProps) { useNavigationResetOnLayoutChange(props); usePreserveNavigatorState(props.state, props.parentRoute); + // Returning null makes Babel skip memoization for this Effects slot; an empty fragment is required. + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; } const WorkspaceNavigatorComponent = createPlatformStackNavigatorComponent(NAVIGATORS.WORKSPACE_NAVIGATOR, { createRouter: WorkspaceRouter, defaultScreenOptions: defaultPlatformStackScreenOptions, - useCustomEffects, + Effects: WorkspaceNavigatorEffects, }); function createWorkspaceNavigator< diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx index 73a34660d98c..72dabfccd04d 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx @@ -1,3 +1,5 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + import convertToNativeNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToNativeNavigationOptions'; import screenLayout from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type { @@ -14,143 +16,130 @@ import type {NativeStackNavigationEventMap, NativeStackNavigationOptions} from ' import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; import {NativeStackView} from '@react-navigation/native-stack'; -import React, {useMemo} from 'react'; +import React from 'react'; import wrapDescriptorsWithFreeze from './wrapDescriptorsWithFreeze'; -type PlatformNavigatorBindings = { +type PlatformNavigatorImplProps = PlatformStackNavigatorProps & { createRouter: NonNullable['createRouter']>; - useCustomState: NonNullable['useCustomState']>; + getCustomState?: CreatePlatformStackNavigatorComponentOptions['getCustomState']; defaultScreenOptions?: CreatePlatformStackNavigatorComponentOptions['defaultScreenOptions']; ExtraContent?: CreatePlatformStackNavigatorComponentOptions['ExtraContent']; NavigationContentWrapper?: CreatePlatformStackNavigatorComponentOptions['NavigationContentWrapper']; - useCustomEffects: NonNullable['useCustomEffects']>; + Effects?: CreatePlatformStackNavigatorComponentOptions['Effects']; freezeNonTopScreens?: boolean; displayName: string; }; -function createPlatformNavigatorImpl({ +function PlatformNavigatorImpl({ + id, + initialRouteName, + screenOptions, + screenListeners, + children, + sidebarScreen, + defaultCentralScreen, + parentRoute, createRouter, - useCustomState, + getCustomState, defaultScreenOptions, ExtraContent, NavigationContentWrapper, - useCustomEffects, + Effects, freezeNonTopScreens, displayName, -}: PlatformNavigatorBindings) { - function PlatformNavigatorImpl({ - id, - initialRouteName, - screenOptions, - screenListeners, - children, - sidebarScreen, - defaultCentralScreen, + ...props +}: PlatformNavigatorImplProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const { + navigation, + state: originalState, + descriptors, + describe, + NavigationContent, + } = useNavigationBuilder< + PlatformStackNavigationState, + RouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap, + PlatformStackNavigationOptions + >( + createRouter, + { + id, + children, + screenOptions: {...defaultScreenOptions, ...screenOptions}, + screenListeners, + initialRouteName, + sidebarScreen, + defaultCentralScreen, + parentRoute, + screenLayout, + }, + convertToNativeNavigationOptions, + ); + + const customCodeProps: CustomCodeProps> = { + state: originalState, + navigation, + descriptors, + displayName, parentRoute, - ...props - }: PlatformStackNavigatorProps) { - const { - navigation, - state: originalState, - descriptors, - describe, - NavigationContent, - } = useNavigationBuilder< - PlatformStackNavigationState, - RouterOptions, - StackActionHelpers, - NativeStackNavigationOptions, - NativeStackNavigationEventMap, - PlatformStackNavigationOptions - >( - createRouter, - { - id, - children, - screenOptions: {...defaultScreenOptions, ...screenOptions}, - screenListeners, - initialRouteName, - sidebarScreen, - defaultCentralScreen, - parentRoute, - screenLayout, - }, - convertToNativeNavigationOptions, - ); - - const customCodeProps = useMemo>>( - () => ({ - state: originalState, - navigation, - descriptors, - displayName, - parentRoute, - }), - [originalState, navigation, descriptors, parentRoute], - ); - - const stateToRender = useCustomState(customCodeProps); - const state = useMemo(() => stateToRender ?? originalState, [originalState, stateToRender]); - const customCodePropsWithCustomState = useMemo>>( - () => ({ - ...customCodeProps, - state, - }), - [customCodeProps, state], - ); - - // Executes custom effects defined in "useCustomEffects" navigator option. - useCustomEffects(customCodePropsWithCustomState); - - const wrappedDescriptors = freezeNonTopScreens ? wrapDescriptorsWithFreeze(descriptors, state) : descriptors; - - const Content = useMemo( - () => ( - - - {!!ExtraContent && } - - ), - [NavigationContent, customCodePropsWithCustomState, describe, wrappedDescriptors, navigation, props, state], - ); - - return NavigationContentWrapper === undefined ? Content : {Content}; - } - - return PlatformNavigatorImpl; + }; + + const state = getCustomState?.({...customCodeProps, shouldUseNarrowLayout}) ?? originalState; + const customCodePropsWithCustomState: CustomCodeProps> = { + ...customCodeProps, + state, + }; + + const wrappedDescriptors = freezeNonTopScreens ? wrapDescriptorsWithFreeze(descriptors, state) : descriptors; + + const content = ( + + + {!!ExtraContent && } + + ); + + return ( + <> + {!!Effects && } + {NavigationContentWrapper === undefined ? content : {content}} + + ); } function createPlatformStackNavigatorComponent( displayName: string, options?: CreatePlatformStackNavigatorComponentOptions, ) { - const PlatformNavigatorImpl = createPlatformNavigatorImpl({ - createRouter: options?.createRouter ?? StackRouter, - useCustomState: (options?.useCustomState ?? (() => undefined)) as NonNullable['useCustomState']>, - defaultScreenOptions: options?.defaultScreenOptions, - ExtraContent: options?.ExtraContent, - NavigationContentWrapper: options?.NavigationContentWrapper, - useCustomEffects: options?.useCustomEffects ?? (() => undefined), - freezeNonTopScreens: options?.freezeNonTopScreens, - displayName, - }); - function PlatformNavigator(props: PlatformStackNavigatorProps) { - return ; + return ( + + ); } - // OXC's React Compiler does not memoize this generated navigator on web; memoize it explicitly. - const MemoizedPlatformNavigator = React.memo(PlatformNavigator); - MemoizedPlatformNavigator.displayName = displayName; + PlatformNavigator.displayName = displayName; - return MemoizedPlatformNavigator; + return PlatformNavigator; } export default createPlatformStackNavigatorComponent; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index 76e316b461da..73684328be1d 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -1,3 +1,5 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + import convertToWebNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions'; import screenLayout from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type { @@ -14,157 +16,143 @@ import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navig import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; import {StackView} from '@react-navigation/stack'; -import React, {useMemo} from 'react'; +import React from 'react'; import wrapDescriptorsWithFreeze from './wrapDescriptorsWithFreeze'; -type PlatformNavigatorBindings = { +type PlatformNavigatorImplProps = PlatformStackNavigatorProps & { createRouter: NonNullable['createRouter']>; - useCustomState: NonNullable['useCustomState']>; + getCustomState?: CreatePlatformStackNavigatorComponentOptions['getCustomState']; defaultScreenOptions?: CreatePlatformStackNavigatorComponentOptions['defaultScreenOptions']; ExtraContent?: CreatePlatformStackNavigatorComponentOptions['ExtraContent']; NavigationContentWrapper?: CreatePlatformStackNavigatorComponentOptions['NavigationContentWrapper']; - useCustomEffects: NonNullable['useCustomEffects']>; + Effects?: CreatePlatformStackNavigatorComponentOptions['Effects']; freezeNonTopScreens?: boolean; displayName: string; }; -function createPlatformNavigatorImpl({ +function PlatformNavigatorImpl({ + id, + initialRouteName, + screenOptions, + screenListeners, + children, + sidebarScreen, + defaultCentralScreen, + parentRoute, + persistentScreens, createRouter, - useCustomState, + getCustomState, defaultScreenOptions, ExtraContent, NavigationContentWrapper, - useCustomEffects, + Effects, freezeNonTopScreens, displayName, -}: PlatformNavigatorBindings) { - function PlatformNavigatorImpl({ - id, - initialRouteName, - screenOptions, - screenListeners, - children, - sidebarScreen, - defaultCentralScreen, + ...props +}: PlatformNavigatorImplProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const { + navigation, + state: originalState, + descriptors, + describe, + NavigationContent, + } = useNavigationBuilder< + PlatformStackNavigationState, + RouterOptions, + StackActionHelpers, + StackNavigationOptions, + StackNavigationEventMap, + PlatformStackNavigationOptions + >( + createRouter, + { + id, + children, + screenOptions: {...defaultScreenOptions, ...screenOptions}, + screenListeners, + initialRouteName, + defaultCentralScreen, + sidebarScreen, + parentRoute, + persistentScreens, + screenLayout, + }, + convertToWebNavigationOptions, + ); + + const customCodeProps: CustomCodeProps> = { + state: originalState, + navigation, + descriptors, + displayName, parentRoute, - persistentScreens, - ...props - }: PlatformStackNavigatorProps) { - const { - navigation, - state: originalState, - descriptors, - describe, - NavigationContent, - } = useNavigationBuilder< - PlatformStackNavigationState, - RouterOptions, - StackActionHelpers, - StackNavigationOptions, - StackNavigationEventMap, - PlatformStackNavigationOptions - >( - createRouter, - { - id, - children, - screenOptions: {...defaultScreenOptions, ...screenOptions}, - screenListeners, - initialRouteName, - defaultCentralScreen, - sidebarScreen, - parentRoute, - persistentScreens, - screenLayout, - }, - convertToWebNavigationOptions, - ); - - const customCodeProps = useMemo>>( - () => ({ - state: originalState, - navigation, - descriptors, - displayName, - parentRoute, - }), - [originalState, navigation, descriptors, parentRoute], - ); - - const stateToRender = useCustomState(customCodeProps); - const state = useMemo(() => stateToRender ?? originalState, [originalState, stateToRender]); - const customCodePropsWithCustomState = useMemo>>( - () => ({ - ...customCodeProps, - state, - }), - [customCodeProps, state], - ); - // Executes custom effects defined in "useCustomEffects" navigator option. - useCustomEffects(customCodePropsWithCustomState); - - const mappedState = useMemo(() => { - return { - ...state, - routes: state.routes.map((route) => { - // eslint-disable-next-line rulesdir/no-negated-variables - const dontDetachScreen = persistentScreens?.includes(route.name) ? {dontDetachScreen: true} : {}; - return {...route, ...dontDetachScreen}; - }), - }; - }, [persistentScreens, state]); - - const wrappedDescriptors = freezeNonTopScreens ? wrapDescriptorsWithFreeze(descriptors, state, persistentScreens) : descriptors; - - const Content = useMemo( - () => ( - - - - {!!ExtraContent && } - - ), - [NavigationContent, customCodePropsWithCustomState, describe, wrappedDescriptors, mappedState, navigation, props], - ); - - return NavigationContentWrapper === undefined ? Content : {Content}; - } - - return PlatformNavigatorImpl; + }; + + const state = getCustomState?.({...customCodeProps, shouldUseNarrowLayout}) ?? originalState; + const customCodePropsWithCustomState: CustomCodeProps> = { + ...customCodeProps, + state, + }; + + const mappedState = { + ...state, + routes: state.routes.map((route) => { + // eslint-disable-next-line rulesdir/no-negated-variables + const dontDetachScreen = persistentScreens?.includes(route.name) ? {dontDetachScreen: true} : {}; + return {...route, ...dontDetachScreen}; + }), + }; + + const wrappedDescriptors = freezeNonTopScreens ? wrapDescriptorsWithFreeze(descriptors, state, persistentScreens) : descriptors; + + const content = ( + + + + {!!ExtraContent && } + + ); + + return ( + <> + {!!Effects && } + {NavigationContentWrapper === undefined ? content : {content}} + + ); } function createPlatformStackNavigatorComponent( displayName: string, options?: CreatePlatformStackNavigatorComponentOptions, ) { - const PlatformNavigatorImpl = createPlatformNavigatorImpl({ - createRouter: options?.createRouter ?? StackRouter, - useCustomState: (options?.useCustomState ?? (() => undefined)) as NonNullable['useCustomState']>, - defaultScreenOptions: options?.defaultScreenOptions, - ExtraContent: options?.ExtraContent, - NavigationContentWrapper: options?.NavigationContentWrapper, - useCustomEffects: options?.useCustomEffects ?? (() => undefined), - freezeNonTopScreens: options?.freezeNonTopScreens, - displayName, - }); - function PlatformNavigator(props: PlatformStackNavigatorProps) { - return ; + return ( + + ); } - // OXC's React Compiler does not memoize this generated navigator on web; memoize it explicitly. - const MemoizedPlatformNavigator = React.memo(PlatformNavigator); - MemoizedPlatformNavigator.displayName = displayName; + PlatformNavigator.displayName = displayName; - return MemoizedPlatformNavigator; + return PlatformNavigator; } export default createPlatformStackNavigatorComponent; diff --git a/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts b/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts index 146fced11174..c377dbd49659 100644 --- a/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts +++ b/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts @@ -25,17 +25,19 @@ type CustomCodeProps< parentRoute?: RouteProp; }; -// Props for the custom state hook. -type CustomStateHookProps = CustomCodeProps; +// Props for getCustomState. shouldUseNarrowLayout is provided by PlatformNavigatorImpl so transforms stay pure (no hooks). +type CustomStateHookProps = CustomCodeProps & { + shouldUseNarrowLayout: boolean; +}; -// Defines a hook function type for transforming the navigation state based on props, and returning the transformed state. -type CustomStateHook = (props: CustomStateHookProps) => PlatformStackNavigationState; +// Plain function that transforms navigation state. Must not call React hooks. +type GetCustomState = (props: CustomStateHookProps) => PlatformStackNavigationState | undefined; -// Props for the custom effects hook. +// Props for the Effects component (same shape as ExtraContent custom code props). type CustomEffectsHookProps = CustomCodeProps; -// Defines a hook function type for creating custom effects in the navigator. -type CustomEffectsHook = (props: CustomEffectsHookProps) => void; +// A React component that runs navigator side effects (hooks) and renders nothing. +type NavigatorEffects = (props: CustomEffectsHookProps) => React.ReactElement | null; // Props for the ExtraContent component. type ExtraContentProps = CustomCodeProps; @@ -53,8 +55,8 @@ type NavigationContentWrapper = (props: NavigationContentWrapperProps) => React. type CreatePlatformStackNavigatorComponentOptions = { createRouter?: PlatformStackRouterFactory; defaultScreenOptions?: PlatformStackNavigationOptions; - useCustomState?: CustomStateHook; - useCustomEffects?: CustomEffectsHook; + getCustomState?: GetCustomState; + Effects?: NavigatorEffects; ExtraContent?: ExtraContent; NavigationContentWrapper?: NavigationContentWrapper; freezeNonTopScreens?: boolean; From 783123e11bb76b1e3b9826923cb9e741374bc007 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 14:32:02 -0700 Subject: [PATCH 10/11] fix: remount OutcomeScreen when illustration changes useMemoizedLazyAsset freezes the first importFn; key remounts the one dynamic caller so illustration updates still load. Co-authored-by: Cursor --- .../OutcomeScreen/OutcomeScreenBase.tsx | 27 ++++++++++++++++++- src/hooks/useLazyAsset.ts | 4 +++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx b/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx index daa11d255d93..a00b7f57a520 100644 --- a/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx +++ b/src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx @@ -62,9 +62,21 @@ function HTMLSubtitle({htmlString = '', style}: {htmlString?: string; style?: Vi ); } -function OutcomeScreenBase({headerTitle, illustration, iconWidth, iconHeight, title, subtitle, customSubtitle, padding, onClose: onCloseOverride, titleStyle}: OutcomeScreenBaseProps) { +function OutcomeScreenBaseContent({ + headerTitle, + illustration, + iconWidth, + iconHeight, + title, + subtitle, + customSubtitle, + padding, + onClose: onCloseOverride, + titleStyle, +}: OutcomeScreenBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + // useMemoizedLazyAsset freezes the first importFn; remount when illustration changes (see OutcomeScreenBase key). const {asset: icon} = useMemoizedLazyAsset(() => loadIllustration(illustration)); const {dispatch} = useMultifactorAuthenticationActions(); @@ -116,6 +128,19 @@ function OutcomeScreenBase({headerTitle, illustration, iconWidth, iconHeight, ti ); } +/** + * Remounts content when `illustration` changes so useMemoizedLazyAsset picks up a new loader. + * That hook intentionally freezes the first importFn to avoid infinite loops from inline loaders. + */ +function OutcomeScreenBase(props: OutcomeScreenBaseProps) { + return ( + + ); +} + OutcomeScreenBase.displayName = 'OutcomeScreenBase'; export default OutcomeScreenBase; diff --git a/src/hooks/useLazyAsset.ts b/src/hooks/useLazyAsset.ts index 440a7efeab96..869380fc0c89 100644 --- a/src/hooks/useLazyAsset.ts +++ b/src/hooks/useLazyAsset.ts @@ -93,6 +93,10 @@ function useLazyAsset(importFn: () => {default: T} | Promise<{default: T}>, f * This prevents the need for callers to manually use useCallback * Returns guaranteed non-null assets for existing components compatibility * Supports both synchronous and async return values for optimal performance + * + * Captures the first `importFn` only (via useState initializer). If the loader closes over a value + * that can change across renders (e.g. a dynamic illustration name), remount the consumer with a + * `key` tied to that value so a new importFn is captured. */ function useMemoizedLazyAsset(importFn: () => {default: T} | Promise<{default: T}>, fallback?: T): {asset: T} { // Capture the first importFn only. Callers pass inline loaders that close over constant asset From 4cc9ef12590c5e8f01495b7ffc627f139e86e23b Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 15 Jul 2026 14:47:45 -0700 Subject: [PATCH 11/11] fix: wrap OXC-memoized SVG elements instead of blanking them resolveIconComponent was replacing pre-rendered elements with empty PlaceholderIcon (including host ), 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 --- src/hooks/useLazyAsset.ts | 55 +++++++++++++++++++-------- tests/unit/hooks/useLazyAsset.test.ts | 15 ++++++++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/hooks/useLazyAsset.ts b/src/hooks/useLazyAsset.ts index 869380fc0c89..a636f09959a3 100644 --- a/src/hooks/useLazyAsset.ts +++ b/src/hooks/useLazyAsset.ts @@ -6,13 +6,38 @@ import PlaceholderIcon from '@components/Icon/PlaceholderIcon'; import type IconAsset from '@src/types/utils/IconAsset'; -import {isValidElement, useEffect, useMemo, useRef, useState} from 'react'; +import type {ReactElement} from 'react'; +import type {SvgProps} from 'react-native-svg'; + +import {cloneElement, isValidElement, useEffect, useMemo, useRef, useState} from 'react'; + +/** + * OXC can cache SVG assets as pre-rendered elements (including host `` nodes). + * ImageSVG expects a component, so wrap those elements. Replacing them with PlaceholderIcon + * blanks illustrations; unwrapping `.type` fails when type is the string `'svg'`. + */ +const wrappedElementIconCache = new WeakMap(); + +function wrapElementAsIcon(element: ReactElement): IconAsset { + const cached = wrappedElementIconCache.get(element); + if (cached) { + return cached; + } + + const WrappedIcon = (props: SvgProps) => cloneElement(element, props); + wrappedElementIconCache.set(element, WrappedIcon); + return WrappedIcon; +} function resolveIconComponent(asset: IconAsset | undefined, fallback: IconAsset = PlaceholderIcon): IconAsset { - if (asset == null || isValidElement(asset)) { + if (asset == null) { return fallback; } + if (isValidElement(asset)) { + return wrapElementAsIcon(asset); + } + return asset; } @@ -168,13 +193,12 @@ function useMemoizedLazyIllustrationsImpl(names: readonly IllustrationName[]): R }; }, [namesList, cachedChunk]); - return useMemo(() => { - const icons: Record = {}; - for (const name of namesList) { - icons[name] = resolveIconComponent(assets[name]); - } - return icons; - }, [assets, namesList]); + // Do not wrap this mapping in useMemo — OXC can fuse it into pre-rendered elements (#96158). + const icons: Record = {}; + for (const name of namesList) { + icons[name] = resolveIconComponent(assets[name]); + } + return icons; } /** @@ -245,13 +269,12 @@ function useMemoizedLazyExpensifyIconsImpl(names: readonly ExpensifyIconName[]): }; }, [namesList, cachedChunk]); - return useMemo(() => { - const icons: Record = {}; - for (const name of namesList) { - icons[name] = resolveIconComponent(assets[name]); - } - return icons; - }, [assets, namesList]); + // Do not wrap this mapping in useMemo — OXC can fuse it into pre-rendered elements (#96158). + const icons: Record = {}; + for (const name of namesList) { + icons[name] = resolveIconComponent(assets[name]); + } + return icons; } /** diff --git a/tests/unit/hooks/useLazyAsset.test.ts b/tests/unit/hooks/useLazyAsset.test.ts index 8d10291869c1..9206714f0f2a 100644 --- a/tests/unit/hooks/useLazyAsset.test.ts +++ b/tests/unit/hooks/useLazyAsset.test.ts @@ -337,6 +337,21 @@ describe('useMemoizedLazyAsset', () => { expect(result.current.asset).toBe(mockAsset); expect(importFn).toHaveBeenCalled(); }); + + it('wraps OXC-memoized host SVG elements as components instead of PlaceholderIcon', async () => { + // Host elements have type === 'svg'; unwrapping .type cannot recover a component. + const hostSvgElement = React.createElement('svg', {viewBox: '0 0 10 10'}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- fixture: OXC can cache a host element as the loaded asset + const hostSvgAsAsset = hostSvgElement as unknown as IconAsset; + const importFn = jest.fn(() => Promise.resolve({default: hostSvgAsAsset})); + + const {result} = renderHook(() => useMemoizedLazyAsset(importFn)); + + await waitFor(() => { + expect(result.current.asset).not.toBe(hostSvgAsAsset); + expect(typeof result.current.asset).toBe('function'); + }); + }); }); describe('useMemoizedLazyExpensifyIcons', () => {