From d61d30679573e7078e669685932def18d76e9c2a Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Fri, 21 Aug 2026 00:55:59 +0000 Subject: [PATCH 1/9] Fetch policy categories and tags on demand in shared pickers High-traffic accounts load Onyx lazily, so on a fresh sign-in the policyCategories/policyTags collections can be missing or contain only the value already on the expense, leaving pickers stuck showing just the selected item across inline cells, bulk edit, and the IOU tag step. Add useLoadPolicyCategories/useLoadPolicyTags hooks that backfill the full list on demand (once per policyID per session, deduped at module level since API.read does not dedupe reads) and wire them into the shared CategoryPicker and TagPicker so every consumer benefits. Show the list loading state while the fetch is in flight instead of flashing the selected-only fallback. Co-authored-by: thelullabyy --- src/components/CategoryPicker/index.tsx | 9 +++++ src/components/TagPicker/index.tsx | 11 ++++++ src/hooks/useLoadPolicyCategories.ts | 43 +++++++++++++++++++++++ src/hooks/useLoadPolicyTags.ts | 46 +++++++++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 src/hooks/useLoadPolicyCategories.ts create mode 100644 src/hooks/useLoadPolicyTags.ts diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index b1c6de2f1d3b..94acfb175a6c 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -5,6 +5,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDebouncedState from '@hooks/useDebouncedState'; +import useLoadPolicyCategories from '@hooks/useLoadPolicyCategories'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -66,6 +67,9 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); const {isOffline} = useNetwork(); + // Backfill the policy's categories on demand so lazy-loaded accounts don't get stuck showing only the selected category. + useLoadPolicyCategories(policyID); + const {translate, localeCompare} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; @@ -109,6 +113,10 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt const categoriesCount = getEnabledCategoriesCount(categories); const selectedOptionKey = categoryData.find((category) => category.searchText === selectedCategory)?.keyForList; + // While the on-demand fetch above is in flight (collection not yet in Onyx and we're online), show the list + // loading state instead of flashing the selected-only fallback. + const isLoadingNewOptions = !isOffline && policyCategories === undefined && policyCategoriesDraft === undefined; + const textInputOptions = { value: searchValue, label: translate('common.search'), @@ -127,6 +135,7 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt ListItem={SingleSelectListItem} shouldShowTextInput={categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT} textInputOptions={textInputOptions} + isLoadingNewOptions={isLoadingNewOptions} initiallyFocusedItemKey={selectedOptionKey} addBottomSafeAreaPadding={addBottomSafeAreaPadding} style={{listItemTitleStyles: styles.w100}} diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index c0067eef70a8..d804e855ab3b 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -3,7 +3,9 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLoadPolicyTags from '@hooks/useLoadPolicyTags'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -102,8 +104,12 @@ function TagPicker({ const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); const {translate, localeCompare} = useLocalize(); + const {isOffline} = useNetwork(); const [searchValue, setSearchValue] = useState(''); + // Backfill the policy's tags on demand so lazy-loaded accounts don't get stuck showing only the selected tag. + useLoadPolicyTags(policyID); + const policyRecentlyUsedTagsList = useMemo(() => policyRecentlyUsedTags?.[tagListName] ?? [], [policyRecentlyUsedTags, tagListName]); const policyTagList = getTagList(policyTags, tagListIndex); const selectedOptions = getSelectedOptions(selectedTag); @@ -173,6 +179,10 @@ function TagPicker({ const selectedOptionKey = sections.at(0)?.data?.find((policyTag) => policyTag.searchText === selectedTag)?.keyForList; + // While the on-demand fetch above is in flight (collection not yet in Onyx and we're online), show the list + // loading state instead of flashing the selected-only fallback. + const isLoadingNewOptions = !isOffline && policyTags === undefined; + const textInputOptions = { value: searchValue, onChangeText: setSearchValue, @@ -193,6 +203,7 @@ function TagPicker({ }} textInputOptions={textInputOptions} shouldShowTextInput={availableTagsCount >= CONST.STANDARD_LIST_ITEM_LIMIT} + isLoadingNewOptions={isLoadingNewOptions} initiallyFocusedItemKey={selectedOptionKey} onSelectRow={onSubmit} isRowMultilineSupported diff --git a/src/hooks/useLoadPolicyCategories.ts b/src/hooks/useLoadPolicyCategories.ts new file mode 100644 index 000000000000..71e2f2a9391b --- /dev/null +++ b/src/hooks/useLoadPolicyCategories.ts @@ -0,0 +1,43 @@ +import {getPolicyCategories} from '@libs/actions/Policy/Category'; + +import {useEffect} from 'react'; + +import useNetwork from './useNetwork'; + +/** + * Policy IDs whose categories we've already requested during this app session. + * Kept at module level (not a per-instance ref) so that multiple picker instances + * mounting for the same policy don't each fire a redundant `GetPolicyCategories` + * read — `API.read` does not dedupe in-flight reads, so we guard here instead. + */ +const requestedPolicyCategoryIDs = new Set(); + +/** + * Fetches a policy's categories on demand when a category picker mounts. + * + * High-traffic accounts load Onyx data lazily, so on a fresh sign-in the + * `policyCategories` collection can be missing or contain only the category + * already on the expense. Any picker surface that reads categories from Onyx + * without fetching (inline cells, bulk edit, etc.) then shows just the selected + * category. Centralizing the fetch here means every picker consumer backfills the + * full list on demand. + * + * The fetch fires once per policyID per session, unconditionally (not gated on the + * current category count): the client has no reliable "collection is complete" + * signal, and a partial collection is indistinguishable from a full one, so gating + * on a count would miss the "only the selected category is present" case. The read + * is idempotent, so re-fetching simply refreshes to the authoritative set. + */ +function useLoadPolicyCategories(policyID: string | undefined) { + const {isOffline} = useNetwork(); + + useEffect(() => { + if (isOffline || !policyID || requestedPolicyCategoryIDs.has(policyID)) { + return; + } + requestedPolicyCategoryIDs.add(policyID); + getPolicyCategories(policyID); + }, [policyID, isOffline]); +} + +export default useLoadPolicyCategories; diff --git a/src/hooks/useLoadPolicyTags.ts b/src/hooks/useLoadPolicyTags.ts new file mode 100644 index 000000000000..a56350466dc6 --- /dev/null +++ b/src/hooks/useLoadPolicyTags.ts @@ -0,0 +1,46 @@ +import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; + +import {useEffect} from 'react'; + +import useNetwork from './useNetwork'; + +/** + * Policy IDs whose tags we've already requested during this app session. + * Kept at module level (not a per-instance ref) so that multiple picker instances + * mounting for the same policy don't each fire a redundant `OpenPolicyTagsPage` + * read — `API.read` does not dedupe in-flight reads, so we guard here instead. + */ +const requestedPolicyTagIDs = new Set(); + +/** + * Fetches a policy's tags on demand when a tag picker mounts. + * + * High-traffic accounts load Onyx data lazily, so on a fresh sign-in the + * `policyTags` collection can be missing or contain only the tag already on the + * expense. Any picker surface that reads tags from Onyx without fetching (inline + * cells, bulk edit, the IOU tag step, etc.) then shows just the selected tag. + * Centralizing the fetch here means every picker consumer backfills the full list + * on demand. + * + * The fetch fires once per policyID per session, unconditionally (not gated on the + * current tag count): the client has no reliable "collection is complete" signal, + * and a partial collection is indistinguishable from a full one, so gating on a + * count would miss the "only the selected tag is present" case. The read is + * idempotent, so re-fetching simply refreshes to the authoritative set. + * + * Note: `OpenPolicyTagsPage` is the only tag read command available to the client + * today (there is no narrow per-transaction tag read), so it is used here. + */ +function useLoadPolicyTags(policyID: string | undefined) { + const {isOffline} = useNetwork(); + + useEffect(() => { + if (isOffline || !policyID || requestedPolicyTagIDs.has(policyID)) { + return; + } + requestedPolicyTagIDs.add(policyID); + openPolicyTagsPage(policyID); + }, [policyID, isOffline]); +} + +export default useLoadPolicyTags; From 21b84e8d4564850a28761e8d342e7612efb1039b Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Sat, 29 Aug 2026 19:33:58 +0000 Subject: [PATCH 2/9] Show the inline category edit icon while policy categories are still unloaded The inline edit permission gated canEditCategory on hasEnabledOptions(policyCategories), so an expense with a missing category on a lazy-loaded account had no edit icon: the collection was absent, the icon stayed hidden, the CategoryPicker never mounted, and the on-demand fetch it now performs never ran. Treat "categories enabled on the policy but the collection has never loaded" as unknown rather than empty and keep the cell editable, matching the existing loading treatment in DynamicIOURequestStepCategory. A loaded-but-empty collection still disables editing. Co-authored-by: thelullabyy --- src/libs/actions/TransactionInlineEdit.ts | 10 ++++- .../TransactionInlineEdit.test.ts | 39 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index c0e468815667..828abfac87f3 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -499,14 +499,20 @@ function getTransactionEditPermissions({ if (!policy?.areCategoriesEnabled && isCategoryMissing(transaction?.category)) { return false; } + // Lazy-loaded accounts may not have the policy's categories in Onyx yet, and an absent collection is + // indistinguishable from a genuinely empty one. Treat "categories enabled but never loaded" as unknown + // rather than empty and keep the cell editable: opening the picker mounts CategoryPicker, which backfills + // the list. Without this the cell deadlocks on an expense with a missing category — the edit icon is + // hidden, so the picker never mounts and the categories are never fetched. + const areCategoriesEnabledButNotLoaded = !!policy?.areCategoriesEnabled && policyCategories === undefined; // Matches MoneyRequestView's shouldShowCategory logic // For policy expenses, check if there's a category or enabled options if (isGroupPolicy(policy)) { - return !!(transaction?.category ?? '') || hasEnabledOptions(policyCategories ?? {}); + return !!(transaction?.category ?? '') || areCategoriesEnabledButNotLoaded || hasEnabledOptions(policyCategories ?? {}); } // For unreported expenses, disable inline category editing while workspace selection is required. if (isUnreported) { - return !shouldSelectPolicyForUnreported && hasEnabledOptions(policyCategories ?? {}); + return !shouldSelectPolicyForUnreported && (areCategoriesEnabledButNotLoaded || hasEnabledOptions(policyCategories ?? {})); } } diff --git a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts index cb99d3b27550..9c24829c531f 100644 --- a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts +++ b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts @@ -304,6 +304,41 @@ describe('TransactionInlineEdit', () => { expect(permissions.canEditCategory).toBe(true); }); + + it('should enable category editing when the category is missing and the policy categories have not loaded yet', () => { + // Lazy-loaded accounts have no policyCategories in Onyx on a fresh sign-in. Without this the cell + // deadlocks: the edit icon stays hidden, so the picker never mounts and never backfills the list. + const permissions = getTransactionEditPermissions({ + ...baseUnreportedParams, + policyCategories: undefined, + }); + + expect(permissions.canEditCategory).toBe(true); + }); + + it('should disable category editing when the category is missing and the loaded policy categories are empty', () => { + const permissions = getTransactionEditPermissions({ + ...baseUnreportedParams, + policyCategories: {}, + }); + + expect(permissions.canEditCategory).toBe(false); + }); + + it('should disable category editing when categories are not enabled on policy and the policy categories have not loaded yet', () => { + const policyWithoutCategories: Policy = { + ...basePolicy, + areCategoriesEnabled: false, + }; + + const permissions = getTransactionEditPermissions({ + ...baseUnreportedParams, + policy: policyWithoutCategories, + policyCategories: undefined, + }); + + expect(permissions.canEditCategory).toBe(false); + }); }); describe('tag permissions', () => { @@ -374,11 +409,13 @@ describe('TransactionInlineEdit', () => { } satisfies TransactionEditPermissions); }); + // `policyCategories: {}` (rather than `undefined`) is what "no available options" means here: an absent + // collection only tells us the lazy-loaded account hasn't fetched it yet, so it stays editable. it('should disable category and tag editing without available options', () => { const permissions = getTransactionEditPermissions({ ...baseUnreportedParams, transaction: unreportedTransaction, - policyCategories: undefined, + policyCategories: {}, policyTags: undefined, }); From 850c760c3054d5f2e8f537c90b297f6e4a4cebaf Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Sat, 29 Aug 2026 19:42:28 +0000 Subject: [PATCH 3/9] Rename areCategoriesEnabledButNotLoaded to satisfy no-negated-variables lint rule Co-authored-by: thelullabyy --- src/libs/actions/TransactionInlineEdit.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index 828abfac87f3..02e60e9d7fbb 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -504,15 +504,15 @@ function getTransactionEditPermissions({ // rather than empty and keep the cell editable: opening the picker mounts CategoryPicker, which backfills // the list. Without this the cell deadlocks on an expense with a missing category — the edit icon is // hidden, so the picker never mounts and the categories are never fetched. - const areCategoriesEnabledButNotLoaded = !!policy?.areCategoriesEnabled && policyCategories === undefined; + const areCategoriesEnabledButUnloaded = !!policy?.areCategoriesEnabled && policyCategories === undefined; // Matches MoneyRequestView's shouldShowCategory logic // For policy expenses, check if there's a category or enabled options if (isGroupPolicy(policy)) { - return !!(transaction?.category ?? '') || areCategoriesEnabledButNotLoaded || hasEnabledOptions(policyCategories ?? {}); + return !!(transaction?.category ?? '') || areCategoriesEnabledButUnloaded || hasEnabledOptions(policyCategories ?? {}); } // For unreported expenses, disable inline category editing while workspace selection is required. if (isUnreported) { - return !shouldSelectPolicyForUnreported && (areCategoriesEnabledButNotLoaded || hasEnabledOptions(policyCategories ?? {})); + return !shouldSelectPolicyForUnreported && (areCategoriesEnabledButUnloaded || hasEnabledOptions(policyCategories ?? {})); } } From eb4aa82a069168eb7b62892553e0ba728a5e6c50 Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Sun, 30 Aug 2026 19:32:13 +0000 Subject: [PATCH 4/9] Address review feedback on the on-demand picker fetch Apply the missing-category fix to tags as well. An absent policyTags collection only means the lazy-loaded account has not fetched it yet, so treat "tags enabled but unloaded" as unknown and keep the inline cell editable. The existing multi-level check still wins once the tags arrive, and usePopoverEditState closes the popover when it does. Retry the on-demand read while the collection is still absent from Onyx instead of recording an attempt forever. A read that failed, a connection that dropped after the online check, or an Onyx clear on sign-out would otherwise leave the picker stuck on its loading state for the rest of the session. Rewrite the three comments flagged by CONSISTENCY-16 as plain sentences without em dashes. Co-authored-by: thelullabyy --- src/hooks/useLoadPolicyCategories.ts | 33 +++++++++++----- src/hooks/useLoadPolicyTags.ts | 33 +++++++++++----- src/libs/actions/TransactionInlineEdit.ts | 11 ++++-- .../TransactionInlineEdit.test.ts | 38 +++++++++++++++++-- 4 files changed, 91 insertions(+), 24 deletions(-) diff --git a/src/hooks/useLoadPolicyCategories.ts b/src/hooks/useLoadPolicyCategories.ts index 71e2f2a9391b..71c25b1070dd 100644 --- a/src/hooks/useLoadPolicyCategories.ts +++ b/src/hooks/useLoadPolicyCategories.ts @@ -1,16 +1,20 @@ import {getPolicyCategories} from '@libs/actions/Policy/Category'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import ONYXKEYS from '@src/ONYXKEYS'; import {useEffect} from 'react'; import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; /** - * Policy IDs whose categories we've already requested during this app session. - * Kept at module level (not a per-instance ref) so that multiple picker instances - * mounting for the same policy don't each fire a redundant `GetPolicyCategories` - * read — `API.read` does not dedupe in-flight reads, so we guard here instead. + * Policy IDs whose categories we have already refreshed during this app session. + * Kept at module level rather than a per instance ref so that multiple picker instances mounting for the same + * policy do not each fire a redundant `GetPolicyCategories` read. `API.read` does not dedupe in flight reads, + * so we guard here instead. */ -const requestedPolicyCategoryIDs = new Set(); +const refreshedPolicyCategoryIDs = new Set(); /** * Fetches a policy's categories on demand when a category picker mounts. @@ -22,22 +26,33 @@ const requestedPolicyCategoryIDs = new Set(); * category. Centralizing the fetch here means every picker consumer backfills the * full list on demand. * - * The fetch fires once per policyID per session, unconditionally (not gated on the + * The refresh fires once per policyID per session, unconditionally (not gated on the * current category count): the client has no reliable "collection is complete" * signal, and a partial collection is indistinguishable from a full one, so gating * on a count would miss the "only the selected category is present" case. The read * is idempotent, so re-fetching simply refreshes to the authoritative set. + * + * While the collection is still absent from Onyx the refresh is retried instead of + * being skipped, because an absent collection means the read never landed: it + * failed, the connection dropped right after the online check, or Onyx was cleared + * by a sign-out. Recording an attempt forever would leave the picker stuck on its + * loading state for the rest of the session. */ function useLoadPolicyCategories(policyID: string | undefined) { const {isOffline} = useNetwork(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); + const hasCategories = policyCategories !== undefined; useEffect(() => { - if (isOffline || !policyID || requestedPolicyCategoryIDs.has(policyID)) { + if (isOffline || !policyID) { + return; + } + if (hasCategories && refreshedPolicyCategoryIDs.has(policyID)) { return; } - requestedPolicyCategoryIDs.add(policyID); + refreshedPolicyCategoryIDs.add(policyID); getPolicyCategories(policyID); - }, [policyID, isOffline]); + }, [policyID, isOffline, hasCategories]); } export default useLoadPolicyCategories; diff --git a/src/hooks/useLoadPolicyTags.ts b/src/hooks/useLoadPolicyTags.ts index a56350466dc6..94a9fc990600 100644 --- a/src/hooks/useLoadPolicyTags.ts +++ b/src/hooks/useLoadPolicyTags.ts @@ -1,16 +1,20 @@ import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import ONYXKEYS from '@src/ONYXKEYS'; import {useEffect} from 'react'; import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; /** - * Policy IDs whose tags we've already requested during this app session. - * Kept at module level (not a per-instance ref) so that multiple picker instances - * mounting for the same policy don't each fire a redundant `OpenPolicyTagsPage` - * read — `API.read` does not dedupe in-flight reads, so we guard here instead. + * Policy IDs whose tags we have already refreshed during this app session. + * Kept at module level rather than a per instance ref so that multiple picker instances mounting for the same + * policy do not each fire a redundant `OpenPolicyTagsPage` read. `API.read` does not dedupe in flight reads, + * so we guard here instead. */ -const requestedPolicyTagIDs = new Set(); +const refreshedPolicyTagIDs = new Set(); /** * Fetches a policy's tags on demand when a tag picker mounts. @@ -22,25 +26,36 @@ const requestedPolicyTagIDs = new Set(); * Centralizing the fetch here means every picker consumer backfills the full list * on demand. * - * The fetch fires once per policyID per session, unconditionally (not gated on the + * The refresh fires once per policyID per session, unconditionally (not gated on the * current tag count): the client has no reliable "collection is complete" signal, * and a partial collection is indistinguishable from a full one, so gating on a * count would miss the "only the selected tag is present" case. The read is * idempotent, so re-fetching simply refreshes to the authoritative set. * + * While the collection is still absent from Onyx the refresh is retried instead of + * being skipped, because an absent collection means the read never landed: it + * failed, the connection dropped right after the online check, or Onyx was cleared + * by a sign-out. Recording an attempt forever would leave the picker stuck on its + * loading state for the rest of the session. + * * Note: `OpenPolicyTagsPage` is the only tag read command available to the client * today (there is no narrow per-transaction tag read), so it is used here. */ function useLoadPolicyTags(policyID: string | undefined) { const {isOffline} = useNetwork(); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`); + const hasTags = policyTags !== undefined; useEffect(() => { - if (isOffline || !policyID || requestedPolicyTagIDs.has(policyID)) { + if (isOffline || !policyID) { + return; + } + if (hasTags && refreshedPolicyTagIDs.has(policyID)) { return; } - requestedPolicyTagIDs.add(policyID); + refreshedPolicyTagIDs.add(policyID); openPolicyTagsPage(policyID); - }, [policyID, isOffline]); + }, [policyID, isOffline, hasTags]); } export default useLoadPolicyTags; diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index 02e60e9d7fbb..a46d7b96eb1b 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -501,8 +501,8 @@ function getTransactionEditPermissions({ } // Lazy-loaded accounts may not have the policy's categories in Onyx yet, and an absent collection is // indistinguishable from a genuinely empty one. Treat "categories enabled but never loaded" as unknown - // rather than empty and keep the cell editable: opening the picker mounts CategoryPicker, which backfills - // the list. Without this the cell deadlocks on an expense with a missing category — the edit icon is + // rather than empty and keep the cell editable. Opening the picker mounts CategoryPicker, which backfills + // the list. Without this the cell deadlocks on an expense with a missing category. The edit icon stays // hidden, so the picker never mounts and the categories are never fetched. const areCategoriesEnabledButUnloaded = !!policy?.areCategoriesEnabled && policyCategories === undefined; // Matches MoneyRequestView's shouldShowCategory logic @@ -521,7 +521,12 @@ function getTransactionEditPermissions({ if (isMultiLevelTags(policyTags)) { return false; } - return !!transaction?.tag || hasEnabledTags(getTagLists(policyTags)); + // Same reasoning as categories above. An absent collection only means the lazy-loaded account has not + // fetched the tags yet, so treat it as unknown and keep the cell editable. Opening the picker mounts + // TagPicker, which backfills the list. If the tags turn out to be multi-level once they arrive, the + // check above flips this back to false and usePopoverEditState closes the open popover. + const areTagsEnabledButUnloaded = !!policy?.areTagsEnabled && policyTags === undefined; + return !!transaction?.tag || areTagsEnabledButUnloaded || hasEnabledTags(getTagLists(policyTags)); } return ( diff --git a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts index 9c24829c531f..b91c90c5f6d7 100644 --- a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts +++ b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts @@ -385,6 +385,38 @@ describe('TransactionInlineEdit', () => { expect(permissions.canEditTag).toBe(true); }); + + it('should enable tag editing when the tag is missing and the policy tags have not loaded yet', () => { + // Same deadlock as categories. With the collection absent the edit icon stays hidden, so TagPicker + // never mounts and never backfills the list. + const permissions = getTransactionEditPermissions({ + ...baseUnreportedParams, + policy: {...basePolicy, areTagsEnabled: true}, + policyTags: undefined, + }); + + expect(permissions.canEditTag).toBe(true); + }); + + it('should disable tag editing when the tag is missing and the loaded policy tags are empty', () => { + const permissions = getTransactionEditPermissions({ + ...baseUnreportedParams, + policy: {...basePolicy, areTagsEnabled: true}, + policyTags: {}, + }); + + expect(permissions.canEditTag).toBe(false); + }); + + it('should disable tag editing when tags are not enabled on policy and the policy tags have not loaded yet', () => { + const permissions = getTransactionEditPermissions({ + ...baseUnreportedParams, + policy: {...basePolicy, areTagsEnabled: false}, + policyTags: undefined, + }); + + expect(permissions.canEditTag).toBe(false); + }); }); describe('unreported expenses', () => { @@ -409,14 +441,14 @@ describe('TransactionInlineEdit', () => { } satisfies TransactionEditPermissions); }); - // `policyCategories: {}` (rather than `undefined`) is what "no available options" means here: an absent - // collection only tells us the lazy-loaded account hasn't fetched it yet, so it stays editable. + // An empty collection rather than an absent one is what "no available options" means here. An absent + // collection only tells us the lazy-loaded account hasn't fetched it yet, so the cell stays editable. it('should disable category and tag editing without available options', () => { const permissions = getTransactionEditPermissions({ ...baseUnreportedParams, transaction: unreportedTransaction, policyCategories: {}, - policyTags: undefined, + policyTags: {}, }); expect(permissions).toMatchObject({ From e44c9c5c2b856eb19e58e4f677c2aa886842c3cf Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Wed, 2 Sep 2026 19:25:44 +0000 Subject: [PATCH 5/9] Shorten the picker fetch comments flagged in review Co-authored-by: thelullabyy --- src/hooks/useLoadPolicyCategories.ts | 30 +++++---------------- src/hooks/useLoadPolicyTags.ts | 33 +++++------------------ src/libs/actions/TransactionInlineEdit.ts | 15 +++++------ 3 files changed, 18 insertions(+), 60 deletions(-) diff --git a/src/hooks/useLoadPolicyCategories.ts b/src/hooks/useLoadPolicyCategories.ts index 71c25b1070dd..410eab03a31b 100644 --- a/src/hooks/useLoadPolicyCategories.ts +++ b/src/hooks/useLoadPolicyCategories.ts @@ -9,34 +9,16 @@ import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; /** - * Policy IDs whose categories we have already refreshed during this app session. - * Kept at module level rather than a per instance ref so that multiple picker instances mounting for the same - * policy do not each fire a redundant `GetPolicyCategories` read. `API.read` does not dedupe in flight reads, - * so we guard here instead. + * Policy IDs whose categories we have already refreshed this session. Kept at module level so that multiple pickers + * mounting for the same policy do not each fire a redundant `GetPolicyCategories` read, since `API.read` does not + * dedupe in flight reads. */ const refreshedPolicyCategoryIDs = new Set(); /** - * Fetches a policy's categories on demand when a category picker mounts. - * - * High-traffic accounts load Onyx data lazily, so on a fresh sign-in the - * `policyCategories` collection can be missing or contain only the category - * already on the expense. Any picker surface that reads categories from Onyx - * without fetching (inline cells, bulk edit, etc.) then shows just the selected - * category. Centralizing the fetch here means every picker consumer backfills the - * full list on demand. - * - * The refresh fires once per policyID per session, unconditionally (not gated on the - * current category count): the client has no reliable "collection is complete" - * signal, and a partial collection is indistinguishable from a full one, so gating - * on a count would miss the "only the selected category is present" case. The read - * is idempotent, so re-fetching simply refreshes to the authoritative set. - * - * While the collection is still absent from Onyx the refresh is retried instead of - * being skipped, because an absent collection means the read never landed: it - * failed, the connection dropped right after the online check, or Onyx was cleared - * by a sign-out. Recording an attempt forever would leave the picker stuck on its - * loading state for the rest of the session. + * Fetches a policy's categories on demand when a category picker mounts, so a lazy-loaded account does not show only + * the category already on the expense. The read is idempotent and fires once per policyID per session, but is retried + * while the collection is still absent from Onyx, which means the read never landed. */ function useLoadPolicyCategories(policyID: string | undefined) { const {isOffline} = useNetwork(); diff --git a/src/hooks/useLoadPolicyTags.ts b/src/hooks/useLoadPolicyTags.ts index 94a9fc990600..9eee6f3385cc 100644 --- a/src/hooks/useLoadPolicyTags.ts +++ b/src/hooks/useLoadPolicyTags.ts @@ -9,37 +9,16 @@ import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; /** - * Policy IDs whose tags we have already refreshed during this app session. - * Kept at module level rather than a per instance ref so that multiple picker instances mounting for the same - * policy do not each fire a redundant `OpenPolicyTagsPage` read. `API.read` does not dedupe in flight reads, - * so we guard here instead. + * Policy IDs whose tags we have already refreshed this session. Kept at module level so that multiple pickers mounting + * for the same policy do not each fire a redundant `OpenPolicyTagsPage` read, since `API.read` does not dedupe in + * flight reads. */ const refreshedPolicyTagIDs = new Set(); /** - * Fetches a policy's tags on demand when a tag picker mounts. - * - * High-traffic accounts load Onyx data lazily, so on a fresh sign-in the - * `policyTags` collection can be missing or contain only the tag already on the - * expense. Any picker surface that reads tags from Onyx without fetching (inline - * cells, bulk edit, the IOU tag step, etc.) then shows just the selected tag. - * Centralizing the fetch here means every picker consumer backfills the full list - * on demand. - * - * The refresh fires once per policyID per session, unconditionally (not gated on the - * current tag count): the client has no reliable "collection is complete" signal, - * and a partial collection is indistinguishable from a full one, so gating on a - * count would miss the "only the selected tag is present" case. The read is - * idempotent, so re-fetching simply refreshes to the authoritative set. - * - * While the collection is still absent from Onyx the refresh is retried instead of - * being skipped, because an absent collection means the read never landed: it - * failed, the connection dropped right after the online check, or Onyx was cleared - * by a sign-out. Recording an attempt forever would leave the picker stuck on its - * loading state for the rest of the session. - * - * Note: `OpenPolicyTagsPage` is the only tag read command available to the client - * today (there is no narrow per-transaction tag read), so it is used here. + * Fetches a policy's tags on demand when a tag picker mounts, so a lazy-loaded account does not show only the tag + * already on the expense. The read is idempotent and fires once per policyID per session, but is retried while the + * collection is still absent from Onyx. `OpenPolicyTagsPage` is the only tag read available to the client today. */ function useLoadPolicyTags(policyID: string | undefined) { const {isOffline} = useNetwork(); diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index a46d7b96eb1b..8a01a92be726 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -499,11 +499,9 @@ function getTransactionEditPermissions({ if (!policy?.areCategoriesEnabled && isCategoryMissing(transaction?.category)) { return false; } - // Lazy-loaded accounts may not have the policy's categories in Onyx yet, and an absent collection is - // indistinguishable from a genuinely empty one. Treat "categories enabled but never loaded" as unknown - // rather than empty and keep the cell editable. Opening the picker mounts CategoryPicker, which backfills - // the list. Without this the cell deadlocks on an expense with a missing category. The edit icon stays - // hidden, so the picker never mounts and the categories are never fetched. + // An absent categories collection only means the lazy-loaded account has not fetched them yet, so treat it + // as unknown and keep the cell editable. Otherwise the edit icon stays hidden on an expense with a missing + // category, so CategoryPicker never mounts to backfill the list. const areCategoriesEnabledButUnloaded = !!policy?.areCategoriesEnabled && policyCategories === undefined; // Matches MoneyRequestView's shouldShowCategory logic // For policy expenses, check if there's a category or enabled options @@ -521,10 +519,9 @@ function getTransactionEditPermissions({ if (isMultiLevelTags(policyTags)) { return false; } - // Same reasoning as categories above. An absent collection only means the lazy-loaded account has not - // fetched the tags yet, so treat it as unknown and keep the cell editable. Opening the picker mounts - // TagPicker, which backfills the list. If the tags turn out to be multi-level once they arrive, the - // check above flips this back to false and usePopoverEditState closes the open popover. + // Same reasoning as categories above, so TagPicker can mount and backfill. If the tags turn out to be + // multi-level once they arrive, the check above flips this back to false and usePopoverEditState closes + // the open popover. const areTagsEnabledButUnloaded = !!policy?.areTagsEnabled && policyTags === undefined; return !!transaction?.tag || areTagsEnabledButUnloaded || hasEnabledTags(getTagLists(policyTags)); } From 05a59bc96d5eb98f254a86f1b3b00648b7607528 Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Sun, 6 Sep 2026 18:01:57 +0000 Subject: [PATCH 6/9] Track read success, not attempt, and mount the picker while tags load Addresses three review comments: - Gate the on-demand category/tag reads on a new RAM-only loading state written from successData, instead of a module-level Set of attempted policyIDs. A failed read no longer blocks every retry for the session. - Render the list loading skeleton via shouldShowLoadingPlaceholder with emptied sections. isLoadingNewOptions alone only feeds the text input, which is unmounted when the list is empty. - Load tags in DynamicIOURequestStepTag too, and treat an absent tags collection as unknown rather than none, so TagPicker mounts and can fetch on a lazy-loaded policy. Co-authored-by: thelullabyy --- src/ONYXKEYS.ts | 8 +++ src/components/CategoryPicker/index.tsx | 14 +++-- src/components/TagPicker/index.tsx | 16 ++--- src/hooks/useLoadPolicyCategories.ts | 59 +++++++++++++------ src/hooks/useLoadPolicyTags.ts | 59 +++++++++++++------ src/libs/actions/Policy/Category.ts | 42 ++++++++++++- src/libs/actions/Policy/Tag.ts | 42 ++++++++++++- .../request/step/DynamicIOURequestStepTag.tsx | 11 +++- src/setup/index.ts | 2 + src/types/onyx/PolicyDataLoadingState.ts | 18 ++++++ src/types/onyx/index.ts | 2 + 11 files changed, 222 insertions(+), 51 deletions(-) create mode 100644 src/types/onyx/PolicyDataLoadingState.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9157d5fd31d1..9fa5620ce7d9 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -922,6 +922,12 @@ const ONYXKEYS = { /** Session-scoped loading flags for the Expensify Card page, keyed by policyID. * Registered as RAM-only in `setup/index.ts`. */ RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE: 'expensifyCardLoadingState_', + /** Session-scoped loading flags for a policy's on-demand categories read, keyed by policyID. + * Registered as RAM-only in `setup/index.ts`. */ + RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE: 'policyCategoriesLoadingState_', + /** Session-scoped loading flags for a policy's on-demand tags read, keyed by policyID. + * Registered as RAM-only in `setup/index.ts`. */ + RAM_ONLY_POLICY_TAGS_LOADING_STATE: 'policyTagsLoadingState_', /** Pagination cursors for a report's action list. */ REPORT_PAGINATION_STATE: 'reportPaginationState_', REPORT_ACTIONS: 'reportActions_', @@ -1508,6 +1514,8 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE]: OnyxTypes.ReportLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE]: OnyxTypes.CompanyCardsLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE]: OnyxTypes.ExpensifyCardLoadingState; + [ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE]: OnyxTypes.PolicyDataLoadingState; + [ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE]: OnyxTypes.PolicyDataLoadingState; [ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE]: OnyxTypes.ReportPaginationState; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: OnyxTypes.ReportActionsDrafts; diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index 6897fd634cf5..986a4c042c77 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -22,6 +22,7 @@ import type {OptionTree} from '@libs/OptionsListUtils/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import React from 'react'; // eslint-disable-next-line no-restricted-imports -- Need original useOnyx to avoid reading partial Search snapshot policy data (GL code flags are trimmed from the snapshot). @@ -68,7 +69,7 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt const {isOffline} = useNetwork(); // Backfill the policy's categories on demand so lazy-loaded accounts don't get stuck showing only the selected category. - useLoadPolicyCategories(policyID); + const {isLoadingPolicyCategories} = useLoadPolicyCategories(policyID); const {translate, localeCompare} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); @@ -113,9 +114,9 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt const categoriesCount = getEnabledCategoriesCount(categories); const selectedOptionKey = categoryData.find((category) => category.searchText === selectedCategory)?.keyForList; - // While the on-demand fetch above is in flight (collection not yet in Onyx and we're online), show the list - // loading state instead of flashing the selected-only fallback. - const isLoadingNewOptions = !isOffline && policyCategories === undefined && policyCategoriesDraft === undefined; + // While the on-demand fetch above is in flight, show the list skeleton instead of flashing the selected-only + // fallback. A draft collection is a complete local list, so it renders immediately rather than waiting on the read. + const isLoadingNewOptions = isLoadingPolicyCategories && policyCategoriesDraft === undefined; const textInputOptions = { value: searchValue, @@ -130,11 +131,14 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt return ( () : sectionsWithNoneOption} onSelectRow={onSubmit} ListItem={SingleSelectListItem} shouldShowTextInput={categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT} textInputOptions={textInputOptions} + shouldShowLoadingPlaceholder={isLoadingNewOptions} isLoadingNewOptions={isLoadingNewOptions} initiallyFocusedItemKey={selectedOptionKey} addBottomSafeAreaPadding={addBottomSafeAreaPadding} diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index 993ca0b36b53..27b2d7ee9e1b 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -5,7 +5,6 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLoadPolicyTags from '@hooks/useLoadPolicyTags'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -20,6 +19,7 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyTag, PolicyTags} from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import React, {useMemo, useState} from 'react'; @@ -107,11 +107,10 @@ function TagPicker({ const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); const {translate, localeCompare} = useLocalize(); - const {isOffline} = useNetwork(); const [searchValue, setSearchValue] = useState(''); // Backfill the policy's tags on demand so lazy-loaded accounts don't get stuck showing only the selected tag. - useLoadPolicyTags(policyID); + const {isLoadingPolicyTags} = useLoadPolicyTags(policyID); const policyRecentlyUsedTagsList = useMemo(() => policyRecentlyUsedTags?.[tagListName] ?? [], [policyRecentlyUsedTags, tagListName]); const policyTagList = getTagList(policyTags, tagListIndex); @@ -182,9 +181,9 @@ function TagPicker({ const selectedOptionKey = sections.at(0)?.data?.find((policyTag) => policyTag.searchText === selectedTag)?.keyForList; - // While the on-demand fetch above is in flight (collection not yet in Onyx and we're online), show the list - // loading state instead of flashing the selected-only fallback. - const isLoadingNewOptions = !isOffline && policyTags === undefined; + // While the on-demand fetch above is in flight, show the list skeleton instead of flashing the selected-only + // fallback. Orphaned tags passed in by the caller are a complete local list, so they render immediately. + const isLoadingNewOptions = isLoadingPolicyTags && !additionalTagsToInclude?.length; const textInputOptions = { value: searchValue, @@ -198,7 +197,9 @@ function TagPicker({ return ( () : sections} ListItem={SingleSelectListItem} style={{ sectionTitleStyles: styles.mt5, @@ -206,6 +207,7 @@ function TagPicker({ }} textInputOptions={textInputOptions} shouldShowTextInput={availableTagsCount >= CONST.STANDARD_LIST_ITEM_LIMIT} + shouldShowLoadingPlaceholder={isLoadingNewOptions} isLoadingNewOptions={isLoadingNewOptions} initiallyFocusedItemKey={selectedOptionKey} onSelectRow={onSubmit} diff --git a/src/hooks/useLoadPolicyCategories.ts b/src/hooks/useLoadPolicyCategories.ts index 410eab03a31b..2e04ec699425 100644 --- a/src/hooks/useLoadPolicyCategories.ts +++ b/src/hooks/useLoadPolicyCategories.ts @@ -1,40 +1,63 @@ -import {getPolicyCategories} from '@libs/actions/Policy/Category'; +import {clearPolicyCategoriesLoadingState, getPolicyCategories} from '@libs/actions/Policy/Category'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; -/** - * Policy IDs whose categories we have already refreshed this session. Kept at module level so that multiple pickers - * mounting for the same policy do not each fire a redundant `GetPolicyCategories` read, since `API.read` does not - * dedupe in flight reads. - */ -const refreshedPolicyCategoryIDs = new Set(); - /** * Fetches a policy's categories on demand when a category picker mounts, so a lazy-loaded account does not show only - * the category already on the expense. The read is idempotent and fires once per policyID per session, but is retried - * while the collection is still absent from Onyx, which means the read never landed. + * the category already on the expense. + * + * The guard is the RAM-only loading state written by `getPolicyCategories`, not "we already tried once": a read that + * failed leaves `hasOnceLoaded` false, so the next mount or reconnect retries it. Gating on the collection existing + * would not work either, since a partial collection holding just the selected category is indistinguishable from a + * complete one. `isLoading` is applied optimistically, so concurrent pickers for the same policy do not each fire a + * redundant `GetPolicyCategories` read (`API.read` does not dedupe in flight reads). */ function useLoadPolicyCategories(policyID: string | undefined) { - const {isOffline} = useNetwork(); + const [loadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${getNonEmptyStringOnyxID(policyID)}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); + const isLoading = !!loadingState?.isLoading; + const hasOnceLoaded = !!loadingState?.hasOnceLoaded; const hasCategories = policyCategories !== undefined; + // The policyID this instance has already dispatched a read for. A failed read clears `isLoading` without setting + // `hasOnceLoaded`, which would otherwise re-satisfy the effect's guard and retry in a tight loop. Scoping the + // attempt to this instance keeps the retry cadence at "next time a picker opens" (or the next reconnect below). + const requestedPolicyIDRef = useRef(undefined); + + const {isOffline} = useNetwork({ + onReconnect: () => { + if (!policyID || hasOnceLoaded) { + return; + } + // A read cut off by the disconnect never gets a response, so its `failureData` never applies and + // `isLoading` would stay true for the rest of the session. Clear it so the effect below can retry. + requestedPolicyIDRef.current = undefined; + clearPolicyCategoriesLoadingState(policyID); + }, + }); + useEffect(() => { - if (isOffline || !policyID) { + if (isOffline || !policyID || isLoading || hasOnceLoaded || requestedPolicyIDRef.current === policyID) { return; } - if (hasCategories && refreshedPolicyCategoryIDs.has(policyID)) { - return; - } - refreshedPolicyCategoryIDs.add(policyID); + requestedPolicyIDRef.current = policyID; getPolicyCategories(policyID); - }, [policyID, isOffline, hasCategories]); + }, [policyID, isOffline, isLoading, hasOnceLoaded]); + + // True only while the read is unsettled AND there is nothing to render yet, so the picker can show a skeleton + // instead of an empty list. An absent loading state means the read has not been dispatched yet, which covers the + // frame before the optimistic `isLoading` lands. A failed read clears `isLoading` without setting `hasOnceLoaded`, + // so this reaches a terminal false rather than leaving the skeleton up forever. Categories already in Onyx are + // rendered right away and refreshed underneath: a stale-but-present list beats a skeleton on every app start. + const isLoadingPolicyCategories = !isOffline && !hasOnceLoaded && !hasCategories && (isLoading || loadingState === undefined); + + return {isLoadingPolicyCategories}; } export default useLoadPolicyCategories; diff --git a/src/hooks/useLoadPolicyTags.ts b/src/hooks/useLoadPolicyTags.ts index 9eee6f3385cc..aa16edac8f54 100644 --- a/src/hooks/useLoadPolicyTags.ts +++ b/src/hooks/useLoadPolicyTags.ts @@ -1,40 +1,63 @@ -import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; +import {clearPolicyTagsLoadingState, openPolicyTagsPage} from '@libs/actions/Policy/Tag'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; -/** - * Policy IDs whose tags we have already refreshed this session. Kept at module level so that multiple pickers mounting - * for the same policy do not each fire a redundant `OpenPolicyTagsPage` read, since `API.read` does not dedupe in - * flight reads. - */ -const refreshedPolicyTagIDs = new Set(); - /** * Fetches a policy's tags on demand when a tag picker mounts, so a lazy-loaded account does not show only the tag - * already on the expense. The read is idempotent and fires once per policyID per session, but is retried while the - * collection is still absent from Onyx. `OpenPolicyTagsPage` is the only tag read available to the client today. + * already on the expense. `OpenPolicyTagsPage` is the only tag read available to the client today. + * + * The guard is the RAM-only loading state written by `openPolicyTagsPage`, not "we already tried once": a read that + * failed leaves `hasOnceLoaded` false, so the next mount or reconnect retries it. Gating on the collection existing + * would not work either, since a partial collection holding just the selected tag is indistinguishable from a + * complete one. `isLoading` is applied optimistically, so concurrent pickers for the same policy do not each fire a + * redundant read (`API.read` does not dedupe in flight reads). */ function useLoadPolicyTags(policyID: string | undefined) { - const {isOffline} = useNetwork(); + const [loadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${getNonEmptyStringOnyxID(policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`); + const isLoading = !!loadingState?.isLoading; + const hasOnceLoaded = !!loadingState?.hasOnceLoaded; const hasTags = policyTags !== undefined; + // The policyID this instance has already dispatched a read for. A failed read clears `isLoading` without setting + // `hasOnceLoaded`, which would otherwise re-satisfy the effect's guard and retry in a tight loop. Scoping the + // attempt to this instance keeps the retry cadence at "next time a picker opens" (or the next reconnect below). + const requestedPolicyIDRef = useRef(undefined); + + const {isOffline} = useNetwork({ + onReconnect: () => { + if (!policyID || hasOnceLoaded) { + return; + } + // A read cut off by the disconnect never gets a response, so its `failureData` never applies and + // `isLoading` would stay true for the rest of the session. Clear it so the effect below can retry. + requestedPolicyIDRef.current = undefined; + clearPolicyTagsLoadingState(policyID); + }, + }); + useEffect(() => { - if (isOffline || !policyID) { + if (isOffline || !policyID || isLoading || hasOnceLoaded || requestedPolicyIDRef.current === policyID) { return; } - if (hasTags && refreshedPolicyTagIDs.has(policyID)) { - return; - } - refreshedPolicyTagIDs.add(policyID); + requestedPolicyIDRef.current = policyID; openPolicyTagsPage(policyID); - }, [policyID, isOffline, hasTags]); + }, [policyID, isOffline, isLoading, hasOnceLoaded]); + + // True only while the read is unsettled AND there is nothing to render yet, so the picker can show a skeleton + // instead of an empty list. An absent loading state means the read has not been dispatched yet, which covers the + // frame before the optimistic `isLoading` lands. A failed read clears `isLoading` without setting `hasOnceLoaded`, + // so this reaches a terminal false rather than leaving the skeleton up forever. Tags already in Onyx are rendered + // right away and refreshed underneath: a stale-but-present list beats a skeleton on every app start. + const isLoadingPolicyTags = !isOffline && !hasOnceLoaded && !hasTags && (isLoading || loadingState === undefined); + + return {isLoadingPolicyTags}; } export default useLoadPolicyTags; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 37086756c81e..6d6e69247084 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -317,7 +317,46 @@ function getPolicyCategories(policyID: string) { policyID, }; - API.read(READ_COMMANDS.GET_POLICY_CATEGORIES, params); + type CategoriesLoadingKey = typeof ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE; + const loadingStateKey = `${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${policyID}` as const; + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: loadingStateKey, + value: {isLoading: true}, + }, + ]; + + // `hasOnceLoaded` is only ever written here, so a read that never landed leaves the policy eligible for a retry. + // The collection existing in Onyx cannot stand in for this: it may hold only the category already on the expense. + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: loadingStateKey, + value: {isLoading: false, hasOnceLoaded: true}, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: loadingStateKey, + value: {isLoading: false}, + }, + ]; + + API.read(READ_COMMANDS.GET_POLICY_CATEGORIES, params, {optimisticData, successData, failureData}); +} + +/** + * Clears the in-flight flag for a policy's categories read. + * + * A read cut off by a disconnect never gets a response, so its `failureData` never applies and `isLoading` would stay + * true for the rest of the session, blocking every retry. Callers clear it on reconnect. + */ +function clearPolicyCategoriesLoadingState(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${policyID}`, {isLoading: false}); } function setWorkspaceCategoryEnabled({ @@ -2144,6 +2183,7 @@ export { downloadCategoriesCSV, enablePolicyCategories, getPolicyCategories, + clearPolicyCategoriesLoadingState, importPolicyCategories, openPolicyCategoriesPage, removePolicyCategoryReceiptsRequired, diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index ba77ff4687d3..e5255861ffa2 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -79,7 +79,46 @@ function openPolicyTagsPage(policyID: string) { policyID, }; - API.read(READ_COMMANDS.OPEN_POLICY_TAGS_PAGE, params); + type TagsLoadingKey = typeof ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE; + const loadingStateKey = `${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${policyID}` as const; + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: loadingStateKey, + value: {isLoading: true}, + }, + ]; + + // `hasOnceLoaded` is only ever written here, so a read that never landed leaves the policy eligible for a retry. + // The collection existing in Onyx cannot stand in for this: it may hold only the tag already on the expense. + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: loadingStateKey, + value: {isLoading: false, hasOnceLoaded: true}, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: loadingStateKey, + value: {isLoading: false}, + }, + ]; + + API.read(READ_COMMANDS.OPEN_POLICY_TAGS_PAGE, params, {optimisticData, successData, failureData}); +} + +/** + * Clears the in-flight flag for a policy's tags read. + * + * A read cut off by a disconnect never gets a response, so its `failureData` never applies and `isLoading` would stay + * true for the rest of the session, blocking every retry. Callers clear it on reconnect. + */ +function clearPolicyTagsLoadingState(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${policyID}`, {isLoading: false}); } type BuildOptimisticPolicyRecentlyUsedTagsProps = { @@ -1549,6 +1588,7 @@ export { enablePolicyTags, setWorkspaceTagRequired, openPolicyTagsPage, + clearPolicyTagsLoadingState, renamePolicyTag, renamePolicyTagList, setWorkspaceTagEnabled, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx index 69f51692108b..03e5afb4de4a 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx @@ -9,6 +9,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLoadPolicyTags from '@hooks/useLoadPolicyTags'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -137,7 +138,15 @@ function DynamicIOURequestStepTag({ return parentTagAtIndex ? [parentTagAtIndex] : undefined; }, [parentTransactionTag, tagListIndex]); - const shouldShowTag = transactionTag || hasEnabledTags(policyTagLists) || !!additionalTagsToInclude?.length; + // Backfill the policy's tags here too, not just inside TagPicker: on a lazy-loaded account an absent collection + // makes `hasEnabledTags` false, so without this the empty state renders, TagPicker never mounts, and the read it + // owns never fires — leaving the user unable to assign a tag to an untagged expense. + const {isLoadingPolicyTags} = useLoadPolicyTags(policyID); + + // An absent tags collection means "not read yet", not "this policy has no tags", so keep the picker mounted while + // the read is in flight and let it show its own loading state. Once the tags land, a genuinely tag-less policy + // falls back to the empty state as before. + const shouldShowTag = transactionTag || hasEnabledTags(policyTagLists) || !!additionalTagsToInclude?.length || isLoadingPolicyTags; const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); diff --git a/src/setup/index.ts b/src/setup/index.ts index 69ed85289d4e..6cf490963bde 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -83,6 +83,8 @@ export default function () { ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE, + ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE, + ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.RAM_ONLY_MERGE_HR_LINK_TOKEN, ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, diff --git a/src/types/onyx/PolicyDataLoadingState.ts b/src/types/onyx/PolicyDataLoadingState.ts new file mode 100644 index 000000000000..638404bda834 --- /dev/null +++ b/src/types/onyx/PolicyDataLoadingState.ts @@ -0,0 +1,18 @@ +/** + * Session-scoped loading state for a policy's on-demand categories/tags read, keyed by policyID. + * + * Frontend-owned and RAM-only so a backend SET on the categories/tags collections cannot wipe the flags. + * `hasOnceLoaded` is written only from `successData`, so a read that never landed leaves it false and the + * next picker mount retries. Tracking success rather than "we tried once" matters because a partial + * collection (only the value already on the expense) is indistinguishable from a complete one, so the + * presence of the collection cannot stand in for a successful read. + */ +type PolicyDataLoadingState = { + /** Whether a read for this policy has succeeded at least once in this session */ + hasOnceLoaded?: boolean; + + /** Whether a read for this policy is currently in flight */ + isLoading?: boolean; +}; + +export default PolicyDataLoadingState; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 403b166f98fc..1d29c2c5832c 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -133,6 +133,7 @@ import type PlaidData from './PlaidData'; import type Policy from './Policy'; import type {AutoReportingOffset, PolicyConnectionName, PolicyConnectionSyncProgress, PolicyReportField, TaxRate, TaxRates, TaxRatesWithDefault} from './Policy'; import type {PolicyCategories, PolicyCategory} from './PolicyCategory'; +import type PolicyDataLoadingState from './PolicyDataLoadingState'; import type {PolicyEmployeeList} from './PolicyEmployee'; import type PolicyEmployee from './PolicyEmployee'; import type PolicyJoinMember from './PolicyJoinMember'; @@ -305,6 +306,7 @@ export type { Policy, PolicyCategories, PolicyCategory, + PolicyDataLoadingState, PolicyVendors, PolicyEmployee, PolicyEmployeeList, From a7c10cb98fc2bcd4fbd59bf08727921cd5ecd11f Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Sun, 6 Sep 2026 21:06:23 +0000 Subject: [PATCH 7/9] Bucket the new picker loading-state keys as safe for Onyx state export Co-authored-by: thelullabyy --- src/libs/ExportOnyxState/common.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 0124fff6b4e2..858ded419082 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -198,6 +198,8 @@ const safeOnyxKeys = new Set([ ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_MANUAL_BILLING, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE, + ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE, + ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES, ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE, From ee49a6a2e8a011e7e21c6873fb5f5dd395ce50a9 Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Fri, 18 Sep 2026 19:54:32 +0000 Subject: [PATCH 8/9] Do not report loading when there is no policyID Without a policyID neither hook dispatches a read, so the loading state key stays absent and the picker rendered a skeleton forever. Gate the flag on policyID so it mirrors the effect's own guard. Co-authored-by: thelullabyy --- src/hooks/useLoadPolicyCategories.ts | 10 +-- src/hooks/useLoadPolicyTags.ts | 10 +-- .../useLoadPolicyCategoriesAndTagsTest.ts | 84 +++++++++++++++++++ 3 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 tests/unit/useLoadPolicyCategoriesAndTagsTest.ts diff --git a/src/hooks/useLoadPolicyCategories.ts b/src/hooks/useLoadPolicyCategories.ts index 2e04ec699425..1da52bce1753 100644 --- a/src/hooks/useLoadPolicyCategories.ts +++ b/src/hooks/useLoadPolicyCategories.ts @@ -50,12 +50,10 @@ function useLoadPolicyCategories(policyID: string | undefined) { getPolicyCategories(policyID); }, [policyID, isOffline, isLoading, hasOnceLoaded]); - // True only while the read is unsettled AND there is nothing to render yet, so the picker can show a skeleton - // instead of an empty list. An absent loading state means the read has not been dispatched yet, which covers the - // frame before the optimistic `isLoading` lands. A failed read clears `isLoading` without setting `hasOnceLoaded`, - // so this reaches a terminal false rather than leaving the skeleton up forever. Categories already in Onyx are - // rendered right away and refreshed underneath: a stale-but-present list beats a skeleton on every app start. - const isLoadingPolicyCategories = !isOffline && !hasOnceLoaded && !hasCategories && (isLoading || loadingState === undefined); + // True only while the read is unsettled and there is nothing to render yet, so the picker shows a skeleton instead + // of an empty list. The `policyID` check mirrors the effect's guard: with no policyID no read is ever dispatched, + // so the absent loading state would otherwise keep the skeleton up forever. + const isLoadingPolicyCategories = !!policyID && !isOffline && !hasOnceLoaded && !hasCategories && (isLoading || loadingState === undefined); return {isLoadingPolicyCategories}; } diff --git a/src/hooks/useLoadPolicyTags.ts b/src/hooks/useLoadPolicyTags.ts index aa16edac8f54..eb22b806839b 100644 --- a/src/hooks/useLoadPolicyTags.ts +++ b/src/hooks/useLoadPolicyTags.ts @@ -50,12 +50,10 @@ function useLoadPolicyTags(policyID: string | undefined) { openPolicyTagsPage(policyID); }, [policyID, isOffline, isLoading, hasOnceLoaded]); - // True only while the read is unsettled AND there is nothing to render yet, so the picker can show a skeleton - // instead of an empty list. An absent loading state means the read has not been dispatched yet, which covers the - // frame before the optimistic `isLoading` lands. A failed read clears `isLoading` without setting `hasOnceLoaded`, - // so this reaches a terminal false rather than leaving the skeleton up forever. Tags already in Onyx are rendered - // right away and refreshed underneath: a stale-but-present list beats a skeleton on every app start. - const isLoadingPolicyTags = !isOffline && !hasOnceLoaded && !hasTags && (isLoading || loadingState === undefined); + // True only while the read is unsettled and there is nothing to render yet, so the picker shows a skeleton instead + // of an empty list. The `policyID` check mirrors the effect's guard: with no policyID no read is ever dispatched, + // so the absent loading state would otherwise keep the skeleton up forever. + const isLoadingPolicyTags = !!policyID && !isOffline && !hasOnceLoaded && !hasTags && (isLoading || loadingState === undefined); return {isLoadingPolicyTags}; } diff --git a/tests/unit/useLoadPolicyCategoriesAndTagsTest.ts b/tests/unit/useLoadPolicyCategoriesAndTagsTest.ts new file mode 100644 index 000000000000..1ffaee47580b --- /dev/null +++ b/tests/unit/useLoadPolicyCategoriesAndTagsTest.ts @@ -0,0 +1,84 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import useLoadPolicyCategories from '@hooks/useLoadPolicyCategories'; +import useLoadPolicyTags from '@hooks/useLoadPolicyTags'; + +import type * as PolicyCategory from '@libs/actions/Policy/Category'; +import type * as PolicyTag from '@libs/actions/Policy/Tag'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** + * Both hooks own the picker's loading flag. Without a policyID no read is ever dispatched, so the loading state key + * stays absent forever and the flag has to settle on false, otherwise the picker sits on a skeleton for good. + */ + +const POLICY_ID = 'A1B2C3'; + +const mockGetPolicyCategories = jest.fn(); +const mockOpenPolicyTagsPage = jest.fn(); + +jest.mock('@libs/actions/Policy/Category', () => { + const actual = jest.requireActual('@libs/actions/Policy/Category'); + return { + ...actual, + getPolicyCategories: (policyID: string) => mockGetPolicyCategories(policyID), + }; +}); + +jest.mock('@libs/actions/Policy/Tag', () => { + const actual = jest.requireActual('@libs/actions/Policy/Tag'); + return { + ...actual, + openPolicyTagsPage: (policyID: string) => mockOpenPolicyTagsPage(policyID), + }; +}); + +const hooks = [ + { + name: 'useLoadPolicyCategories', + renderPicker: (policyID: string | undefined) => renderHook(() => useLoadPolicyCategories(policyID)), + getIsLoading: (result: {isLoadingPolicyCategories?: boolean; isLoadingPolicyTags?: boolean}) => result.isLoadingPolicyCategories, + mockRead: mockGetPolicyCategories, + }, + { + name: 'useLoadPolicyTags', + renderPicker: (policyID: string | undefined) => renderHook(() => useLoadPolicyTags(policyID)), + getIsLoading: (result: {isLoadingPolicyCategories?: boolean; isLoadingPolicyTags?: boolean}) => result.isLoadingPolicyTags, + mockRead: mockOpenPolicyTagsPage, + }, +] as const; + +describe.each(hooks)('$name', ({renderPicker, getIsLoading, mockRead}) => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockRead.mockClear(); + await Onyx.clear(); + }); + + it.each([undefined, ''])('settles on not-loading and dispatches no read when policyID is %p', async (policyID) => { + const {result} = renderPicker(policyID); + + // The flag is read on the very first render, before any Onyx subscription can settle, so it must already be + // false there. Waiting only guards against it flipping true once the subscriptions land. + expect(getIsLoading(result.current)).toBe(false); + await waitFor(() => { + expect(getIsLoading(result.current)).toBe(false); + }); + expect(mockRead).not.toHaveBeenCalled(); + }); + + it('reports loading and dispatches a read when a policyID is given and nothing is cached', async () => { + const {result} = renderPicker(POLICY_ID); + + await waitFor(() => { + expect(mockRead).toHaveBeenCalledWith(POLICY_ID); + }); + expect(getIsLoading(result.current)).toBe(true); + }); +}); From c17b681f0041d85fe4a437ebbb5e154a4d291425 Mon Sep 17 00:00:00 2001 From: "thelullabyy (via MelvinBot)" Date: Sun, 27 Sep 2026 18:21:09 +0000 Subject: [PATCH 9/9] Fix ESLint no-unsafe-return in useLoadPolicyCategoriesAndTagsTest Co-authored-by: thelullabyy --- tests/unit/useLoadPolicyCategoriesAndTagsTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/useLoadPolicyCategoriesAndTagsTest.ts b/tests/unit/useLoadPolicyCategoriesAndTagsTest.ts index 1ffaee47580b..12606399c037 100644 --- a/tests/unit/useLoadPolicyCategoriesAndTagsTest.ts +++ b/tests/unit/useLoadPolicyCategoriesAndTagsTest.ts @@ -17,8 +17,8 @@ import Onyx from 'react-native-onyx'; const POLICY_ID = 'A1B2C3'; -const mockGetPolicyCategories = jest.fn(); -const mockOpenPolicyTagsPage = jest.fn(); +const mockGetPolicyCategories = jest.fn(); +const mockOpenPolicyTagsPage = jest.fn(); jest.mock('@libs/actions/Policy/Category', () => { const actual = jest.requireActual('@libs/actions/Policy/Category');