Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,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_',
Expand Down Expand Up @@ -1574,6 +1580,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;
Expand Down
15 changes: 14 additions & 1 deletion src/components/CategoryPicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,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).
Expand Down Expand Up @@ -66,6 +68,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.
const {isLoadingPolicyCategories} = useLoadPolicyCategories(policyID);

const {translate, localeCompare} = useLocalize();
const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';
Expand Down Expand Up @@ -110,6 +115,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, 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,
label: translate('common.search'),
Expand All @@ -123,11 +132,15 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt

return (
<SelectionListWithSections
sections={sectionsWithTitleStyles}
// The list only renders the skeleton when it has no items, so the sections have to be emptied too.
// Otherwise the selected-only fallback row still shows while the fetch is in flight.
sections={isLoadingNewOptions ? getEmptyArray<never>() : sectionsWithTitleStyles}
onSelectRow={onSubmit}
ListItem={SingleSelectListItem}
shouldShowTextInput={categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT}
textInputOptions={textInputOptions}
shouldShowLoadingPlaceholder={isLoadingNewOptions}
isLoadingNewOptions={isLoadingNewOptions}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render a loading placeholder for an absent collection

When the collection is absent, categoriesCount is zero, so shouldShowTextInput is false; BaseSelectionListWithSections consumes isLoadingNewOptions only in that unmounted text input, while its empty-list skeleton is controlled by shouldShowLoadingPlaceholder. Consequently the picker still renders the selected-only row—or a blank list when nothing is selected—throughout the fetch, allowing interaction with incomplete data instead of showing the loading state described here. The tag picker has the same wiring.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid — fixed in 05a59bc.

Confirmed: isLoadingNewOptions is only read inside textInputComponent(), which returns null when shouldShowTextInput is false. With the collection absent, categoriesCount is 0, so the text input was unmounted and the prop did nothing. The skeleton is gated on itemsCount === 0 && shouldShowLoadingPlaceholder.

Both pickers now pass shouldShowLoadingPlaceholder and empty the sections while the fetch is in flight — src/components/CategoryPicker/index.tsx:132-142, src/components/TagPicker/index.tsx:198-211. Emptying the sections is the load-bearing half: shouldShowLoadingPlaceholder alone still renders the selected-only row, which is the flash this PR is trying to remove.

The skeleton also now has a terminal state. It is driven by the loading state from the fix in the sibling thread rather than by "collection is still undefined", so a failed read ends the skeleton instead of leaving it up for the rest of the session.

initiallyFocusedItemKey={selectedOptionKey}
addBottomSafeAreaPadding={addBottomSafeAreaPadding}
titleNumberOfLines={CONST.TRANSACTION_TAG_AND_CATEGORY_PICKER_MAX_TITLE_LINES}
Expand Down
15 changes: 14 additions & 1 deletion src/components/TagPicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -18,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';

Expand Down Expand Up @@ -103,6 +105,9 @@ function TagPicker({
const {translate, localeCompare} = useLocalize();
const [searchValue, setSearchValue] = useState('');

// Backfill the policy's tags on demand so lazy-loaded accounts don't get stuck showing only the selected tag.
const {isLoadingPolicyTags} = useLoadPolicyTags(policyID);

const policyRecentlyUsedTagsList = useMemo(() => policyRecentlyUsedTags?.[tagListName] ?? [], [policyRecentlyUsedTags, tagListName]);
const policyTagList = getTagList(policyTags, tagListIndex);
const selectedOptions = getSelectedOptions(selectedTag);
Expand Down Expand Up @@ -162,6 +167,10 @@ function TagPicker({

const selectedOptionKey = sections.at(0)?.data?.find((policyTag) => policyTag.searchText === selectedTag)?.keyForList;

// 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,
onChangeText: setSearchValue,
Expand All @@ -174,11 +183,15 @@ function TagPicker({

return (
<SelectionListWithSections
sections={sections}
// The list only renders the skeleton when it has no items, so the sections have to be emptied too.
// Otherwise the selected-only fallback row still shows while the fetch is in flight.
sections={isLoadingNewOptions ? getEmptyArray<never>() : sections}
ListItem={SingleSelectListItem}
style={{sectionTitleStyles: styles.mt5}}
textInputOptions={textInputOptions}
shouldShowTextInput={availableTagsCount >= CONST.STANDARD_LIST_ITEM_LIMIT}
shouldShowLoadingPlaceholder={isLoadingNewOptions}
isLoadingNewOptions={isLoadingNewOptions}
initiallyFocusedItemKey={selectedOptionKey}
onSelectRow={onSubmit}
addBottomSafeAreaPadding={addBottomSafeAreaPadding}
Expand Down
61 changes: 61 additions & 0 deletions src/hooks/useLoadPolicyCategories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {clearPolicyCategoriesLoadingState, getPolicyCategories} from '@libs/actions/Policy/Category';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';

import ONYXKEYS from '@src/ONYXKEYS';

import {useEffect, useRef} from 'react';

import useNetwork from './useNetwork';
import useOnyx from './useOnyx';

/**
* 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 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).
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@MelvinBot Please shorten this comment, it is too long. We only want 2-3 lines comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Shortened from 22 lines to 3 in e44c9c5: src/hooks/useLoadPolicyCategories.ts:18-22. Kept what the hook does, that it fires once per policyID per session, and that it retries while the collection is absent.

function useLoadPolicyCategories(policyID: string | undefined) {
const [loadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${getNonEmptyStringOnyxID(policyID)}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If policyID is undefined could this lead to a permanent skeleton? as loadingState stays undefined, hasCategories stay false

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and fixed in ee49a6a.

With no policyID, the effect returns early on !policyID, so no read is ever dispatched. loadingState therefore stays undefined forever, hasCategories stays false, and isLoadingPolicyCategories evaluated to !isOffline && true && true && (false || true) — permanently true. CategoryPicker then held isLoadingNewOptions true, emptied its sections, and rendered the skeleton with nothing to resolve it.

The flag now mirrors the effect's own guard: src/hooks/useLoadPolicyCategories.ts:53-56. !!policyID also covers the empty string, which is what DynamicDebugDetailsConstantPickerPage defaults to, and which the effect already treated as "no policyID".

Reachable in practice: the bulk-edit category page gets its policyID from useSearchBulkEditPolicyID, which falls back to activePolicyID and returns undefined when that NVP is unset.

Added tests/unit/useLoadPolicyCategoriesAndTagsTest.ts to lock this in — it fails on the previous commit for both hooks and passes now.


view run

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<string>(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 || isLoading || hasOnceLoaded || requestedPolicyIDRef.current === policyID) {
return;
}
requestedPolicyIDRef.current = policyID;
getPolicyCategories(policyID);
}, [policyID, isOffline, isLoading, hasOnceLoaded]);

// 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};
}

export default useLoadPolicyCategories;
61 changes: 61 additions & 0 deletions src/hooks/useLoadPolicyTags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {clearPolicyTagsLoadingState, openPolicyTagsPage} from '@libs/actions/Policy/Tag';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';

import ONYXKEYS from '@src/ONYXKEYS';

import {useEffect, useRef} from 'react';

import useNetwork from './useNetwork';
import useOnyx from './useOnyx';

/**
* 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. `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).
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@MelvinBot Please shorten this comment, it is too long. We only want 2-3 lines comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both tag comments shortened to 3 lines each in e44c9c5: src/hooks/useLoadPolicyTags.ts:11-22. The OpenPolicyTagsPage note is now one clause at the end instead of its own paragraph.

function useLoadPolicyTags(policyID: string | undefined) {
const [loadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${getNonEmptyStringOnyxID(policyID)}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If policyID is undefined could this lead to a permanent skeleton? as loadingState stays undefined, hasTags stay false

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and fixed in ee49a6a — same root cause as the category hook.

With no policyID the effect returns early, loadingState stays undefined, hasTags stays false, and isLoadingPolicyTags was permanently true. Two consequences here rather than one:

  • TagPicker held isLoadingNewOptions true and rendered the skeleton forever.
  • DynamicIOURequestStepTag ORs isLoadingPolicyTags into shouldShowTag, so a policy-less step kept the picker mounted on a permanent skeleton instead of falling back to the empty state.

The flag now mirrors the effect's guard: src/hooks/useLoadPolicyTags.ts:53-56. !!policyID also covers the empty string the Debug picker page defaults to.

Covered by the new tests/unit/useLoadPolicyCategoriesAndTagsTest.ts, which runs both hooks through the undefined and '' cases.


view run

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<string>(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 || isLoading || hasOnceLoaded || requestedPolicyIDRef.current === policyID) {
return;
}
requestedPolicyIDRef.current = policyID;
openPolicyTagsPage(policyID);
}, [policyID, isOffline, isLoading, hasOnceLoaded]);

// 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};
}

export default useLoadPolicyTags;
2 changes: 2 additions & 0 deletions src/libs/ExportOnyxState/masking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ const safeOnyxKeys = new Set<string>([
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,
Expand Down
42 changes: 41 additions & 1 deletion src/libs/actions/Policy/Category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,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<OnyxUpdate<CategoriesLoadingKey>> = [
{
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<OnyxUpdate<CategoriesLoadingKey>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: loadingStateKey,
value: {isLoading: false, hasOnceLoaded: true},
},
];

const failureData: Array<OnyxUpdate<CategoriesLoadingKey>> = [
{
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({
Expand Down Expand Up @@ -2064,6 +2103,7 @@ export {
downloadCategoriesCSV,
enablePolicyCategories,
getPolicyCategories,
clearPolicyCategoriesLoadingState,
importPolicyCategories,
openPolicyCategoriesPage,
removePolicyCategoryReceiptsRequired,
Expand Down
42 changes: 41 additions & 1 deletion src/libs/actions/Policy/Tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,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<OnyxUpdate<TagsLoadingKey>> = [
{
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<OnyxUpdate<TagsLoadingKey>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: loadingStateKey,
value: {isLoading: false, hasOnceLoaded: true},
},
];

const failureData: Array<OnyxUpdate<TagsLoadingKey>> = [
{
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 = {
Expand Down Expand Up @@ -1482,6 +1521,7 @@ export {
deletePolicyTags,
enablePolicyTags,
openPolicyTagsPage,
clearPolicyTagsLoadingState,
renamePolicyTag,
renamePolicyTagList,
setWorkspaceTagEnabled,
Expand Down
Loading
Loading