diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index defbc51b0d66..83c0992a3678 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -90,6 +90,12 @@ const ONYXKEYS = { /** Contains all the personalDetails the user has access to, keyed by accountID */ PERSONAL_DETAILS_LIST: 'personalDetailsList', + /** Maps an agent's optimistic accountID to the real one CreateAgent assigns, so an agent screen opened on the optimistic ID resolves to the real agent. Persisted so it survives a reload (the mapping is sent only once). */ + OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING: 'optimisticAgentAccountIDMapping', + + /** When each OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING entry was written locally, so stale entries can be pruned. */ + OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT: 'optimisticAgentAccountIDMappingCreatedAt', + /** Contains all the private personal details of the user */ PRIVATE_PERSONAL_DETAILS: 'private_personalDetails', @@ -1323,6 +1329,7 @@ const ONYXKEYS = { CARD_FEED_ERRORS: 'cardFeedErrors', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap', + OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES: 'optimisticAgentAccountIDMappingEntries', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1597,6 +1604,8 @@ type OnyxValuesMapping = { [ONYXKEYS.STATUS_DRAFT_CUSTOM_CLEAR_AFTER_DATE]: string; [ONYXKEYS.INPUT_FOCUSED]: boolean; [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList; + [ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING]: OnyxTypes.OptimisticAgentAccountIDMapping; + [ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT]: OnyxTypes.OptimisticAgentAccountIDMappingCreatedAt; [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.PERSONAL_DETAILS_METADATA]: Record; [ONYXKEYS.TASK]: OnyxTypes.Task; @@ -1857,6 +1866,7 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue; + [ONYXKEYS.DERIVED.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES]: OnyxTypes.OptimisticAgentAccountIDMappingEntriesDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/hooks/useResolvedAgentAccountID.ts b/src/hooks/useResolvedAgentAccountID.ts new file mode 100644 index 000000000000..869925822dba --- /dev/null +++ b/src/hooks/useResolvedAgentAccountID.ts @@ -0,0 +1,38 @@ +import {backfillOptimisticAccountIDMappingCreatedAt} from '@libs/actions/Agent'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useEffect} from 'react'; + +import useOnyx from './useOnyx'; + +/** + * Resolves an agent's optimistic accountID to the real one CreateAgent assigns, via the persisted + * `OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING`, so an agent screen opened on the optimistic accountID (even after a reload) + * shows the real agent instead of "Hmm... it's not here". + * + * The derived `OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES` defers its first compute until all its dependency + * connections are established, which can briefly lag behind the raw key on a cold cache — long enough to flash a + * not-found page in between. It's still used for `createdAt` below, which has no such timing sensitivity. + * + * Without a stamped `createdAt`, an entry is invisible to createAgent()'s pruning and never expires — this can + * happen when the mapping arrives via sync from another device/tab that resolved it first. + * + * @returns `[resolvedAccountID, isMappingLoaded]` - a not-found screen should wait for `isMappingLoaded` to avoid a + * brief not-found flash while the mapping loads. No-op (returns the input) when there's no mapping entry. + */ +function useResolvedAgentAccountID(routeAccountID: number): [number, boolean] { + const [realAccountID, mappingMetadata] = useOnyx(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {selector: (mapping) => mapping?.[routeAccountID]}); + const [createdAt, createdAtMetadata] = useOnyx(ONYXKEYS.DERIVED.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES, {selector: (entries) => entries?.[routeAccountID]?.createdAt}); + + useEffect(() => { + if (realAccountID === undefined || createdAt !== undefined || createdAtMetadata.status !== 'loaded') { + return; + } + backfillOptimisticAccountIDMappingCreatedAt(routeAccountID); + }, [realAccountID, createdAt, createdAtMetadata.status, routeAccountID]); + + return [realAccountID ?? routeAccountID, mappingMetadata.status === 'loaded']; +} + +export default useResolvedAgentAccountID; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 0419155fb542..68e863b7824a 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -26,6 +26,8 @@ const onyxKeysToRemove = new Set | ValueOf; +Onyx.connectWithoutView({ + key: ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, + callback: (value) => { + optimisticAccountIDMappingCreatedAt = value; + }, +}); + +function getStaleOptimisticAccountIDMappingUpdates(): AnyOnyxUpdate[] { + const now = Date.now(); + const staleOptimisticAccountIDs = Object.entries(optimisticAccountIDMappingCreatedAt ?? {}) + .filter(([, createdAt]) => now - createdAt > OPTIMISTIC_ACCOUNT_ID_MAPPING_MAX_AGE_MS) + .map(([staleOptimisticAccountID]) => staleOptimisticAccountID); + + if (staleOptimisticAccountIDs.length === 0) { + return []; + } + + const staleEntries = Object.fromEntries(staleOptimisticAccountIDs.map((id) => [id, null])); + return [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, value: staleEntries}, + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, value: staleEntries}, + ]; +} + function openAgentsPage() { - const finallyData: Array> = [ + const finallyData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ARE_AGENTS_LOADED, @@ -30,7 +59,9 @@ function openAgentsPage() { }, ]; - read(READ_COMMANDS.OPEN_AGENTS_PAGE, null, {finallyData}); + const optimisticData = getStaleOptimisticAccountIDMappingUpdates(); + + read(READ_COMMANDS.OPEN_AGENTS_PAGE, null, {optimisticData, finallyData}); } function openProfilePage() { @@ -106,6 +137,7 @@ function createAgent( key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, value: {isOptimisticReport: true}, }, + ...getStaleOptimisticAccountIDMappingUpdates(), ]; const successData: AnyOnyxUpdate[] = [ @@ -129,6 +161,13 @@ function createAgent( key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, value: {isOptimisticReport: false}, }, + + // Stamped here, not where the mapping itself arrives, since that onyxData is backend-owned. + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, + value: {[optimisticAccountID]: Date.now()}, + }, ]; const failureData: AnyOnyxUpdate[] = [ @@ -174,6 +213,15 @@ function createAgent( return {optimisticAccountID, avatarURI, optimisticReportID}; } +/** + * Backfills a createdAt timestamp for a mapping entry this device notices without one — e.g. one that arrived via + * sync from another device/tab that resolved it first, so this device never got the chance to stamp it itself. + * Without a timestamp an entry is invisible to createAgent()'s pruning and never expires. + */ +function backfillOptimisticAccountIDMappingCreatedAt(optimisticAccountID: number) { + Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, {[optimisticAccountID]: Date.now()}); +} + /** * Stash the template chosen in the "New agent" picker so the custom-agent builder can open pre-filled. */ @@ -462,6 +510,7 @@ export { openAgentsPage, openProfilePage, createAgent, + backfillOptimisticAccountIDMappingCreatedAt, setNewAgentTemplate, clearNewAgentTemplate, clearAgentError, diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index d01744852e4f..673bdb1174d0 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -7,6 +7,7 @@ import type {OnyxDerivedValueConfig} from './types'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; import loginToAccountIDMapConfig from './configs/loginToAccountIDMap'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; +import optimisticAgentAccountIDMappingEntriesConfig from './configs/optimisticAgentAccountIDMappingEntries'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList'; import reportAttributesConfig from './configs/reportAttributes'; @@ -28,6 +29,7 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: loginToAccountIDMapConfig, + [ONYXKEYS.DERIVED.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES]: optimisticAgentAccountIDMappingEntriesConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/optimisticAgentAccountIDMappingEntries.ts b/src/libs/actions/OnyxDerived/configs/optimisticAgentAccountIDMappingEntries.ts new file mode 100644 index 000000000000..e565815e40c6 --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/optimisticAgentAccountIDMappingEntries.ts @@ -0,0 +1,28 @@ +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OptimisticAgentAccountIDMappingEntriesDerivedValue} from '@src/types/onyx'; + +/** + * Combines OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING (backend-owned) and OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT + * (client-owned) into one key per optimistic accountID, so consumers read a single always-in-sync source instead + * of two parallel keys that would otherwise have to be kept manually in lockstep. + */ +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES, + dependencies: [ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT], + compute: ([mapping, createdAtByOptimisticAccountID]) => { + if (!mapping) { + return {}; + } + + const entries: OptimisticAgentAccountIDMappingEntriesDerivedValue = {}; + for (const [optimisticAccountID, realAccountID] of Object.entries(mapping)) { + if (realAccountID === undefined) { + continue; + } + entries[optimisticAccountID] = {realAccountID, createdAt: createdAtByOptimisticAccountID?.[optimisticAccountID]}; + } + return entries; + }, +}); diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/EditAgentPage.tsx index c57b80ba7a38..970e7e34149c 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/EditAgentPage.tsx @@ -13,6 +13,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useResolvedAgentAccountID from '@hooks/useResolvedAgentAccountID'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -40,7 +41,9 @@ function EditAgentPage({route}: EditAgentPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Trashcan', 'ChatBubble', 'Users']); - const accountID = route.params.accountID; + + // Resolve the optimistic accountID to the real one so opening this page mid-CreateAgent (or after a reload) doesn't 404. + const [accountID, isResolvedAccountIDLoaded] = useResolvedAgentAccountID(route.params.accountID); const [agent, agentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); const [personalDetails, personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (list) => list?.[accountID]}); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -48,14 +51,19 @@ function EditAgentPage({route}: EditAgentPageProps) { const showRuleBotGuardModal = useRuleBotGuardModal(); const chatWithAgent = useChatWithAgent(); const switchToDelegator = useSwitchToDelegator(); - const isOnyxLoaded = agentMetadata.status === 'loaded' && personalDetailsMetadata.status === 'loaded'; + + // Wait for the optimistic->real mapping to load too, so an optimistic accountID that is about to resolve after a + // reload doesn't briefly flash the not-found page before the mapping is read from storage. + const isOnyxLoaded = isResolvedAccountIDLoaded && agentMetadata.status === 'loaded' && personalDetailsMetadata.status === 'loaded'; const shouldShowNotFoundPage = isOnyxLoaded && !agent && !personalDetails; const agentLogin = personalDetails?.login ?? ''; const handleBackPress = () => Navigation.goBack(); - const handleEditAvatarPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.getRoute(accountID)); - const handleEditNamePress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_NAME.getRoute(accountID)); - const handleEditPromptPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID)); + + // Navigate with the raw route accountID (not the resolved one) so the sub-page URL stays consistent with this page's URL and device back doesn't create a duplicate entry. + const handleEditAvatarPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.getRoute(route.params.accountID)); + const handleEditNamePress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_NAME.getRoute(route.params.accountID)); + const handleEditPromptPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(route.params.accountID)); const handleDeletePress = async () => { const ruleBotEnforcedPolicy = getRuleBotEnforcedPolicy(accountID, allPolicies); if (ruleBotEnforcedPolicy) { diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx index 2500de21e96f..ef515707fcdb 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx @@ -14,6 +14,7 @@ import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation' import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useResolvedAgentAccountID from '@hooks/useResolvedAgentAccountID'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -233,11 +234,12 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset EditAgentAvatarContent.displayName = 'EditAgentAvatarContent'; function EditAgentAvatarPage({route}: EditAgentAvatarPageProps) { - const {accountID} = route.params; + // Resolve the optimistic accountID to the real one so opening this page mid-CreateAgent (or after a reload) doesn't 404. + const [accountID] = useResolvedAgentAccountID(route.params.accountID); return ( ); } diff --git a/src/pages/settings/Agents/Fields/EditNamePage.tsx b/src/pages/settings/Agents/Fields/EditNamePage.tsx index 6012562e1784..70e110c8cfbd 100644 --- a/src/pages/settings/Agents/Fields/EditNamePage.tsx +++ b/src/pages/settings/Agents/Fields/EditNamePage.tsx @@ -8,6 +8,7 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useResolvedAgentAccountID from '@hooks/useResolvedAgentAccountID'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateAgentName} from '@libs/actions/Agent'; @@ -28,14 +29,16 @@ type EditNamePageProps = PlatformStackScreenProps list?.[accountID]}); const {inputCallbackRef} = useAutoFocusInput(); const handleSubmit = (values: FormOnyxValues) => { updateAgentName(accountID, values[INPUT_IDS.FIRST_NAME].trim(), personalDetails?.displayName ?? ''); - Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)); + Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(route.params.accountID)); }; return ( @@ -47,7 +50,7 @@ function EditNamePage({route}: EditNamePageProps) { > Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(route.params.accountID))} /> ): FormInputErrors => { diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 6ffea17b53e7..e3566195b65f 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -264,6 +264,21 @@ type PersonalAndWorkspaceCardListDerivedValue = CardList; */ type LoginToAccountIDMapDerivedValue = Record; +/** One combined entry in OptimisticAgentAccountIDMappingEntriesDerivedValue. */ +type OptimisticAgentAccountIDMappingEntry = { + /** The real accountID CreateAgent assigned for this optimistic accountID. */ + realAccountID: number; + + /** When this entry was noticed locally, in ms since epoch. Undefined until the local stamp/backfill lands. */ + createdAt: number | undefined; +}; + +/** + * Combines OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING and OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT into one + * key per optimistic accountID, so consumers read a single always-in-sync source instead of two parallel keys. + */ +type OptimisticAgentAccountIDMappingEntriesDerivedValue = Record; + export type { ReportAttributes, ReportAttributesDerivedValue, @@ -276,6 +291,7 @@ export type { PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, + OptimisticAgentAccountIDMappingEntriesDerivedValue, CardFeedErrorsObject, CardFeedErrorState, CardFeedErrors, diff --git a/src/types/onyx/OptimisticAgentAccountIDMapping.ts b/src/types/onyx/OptimisticAgentAccountIDMapping.ts new file mode 100644 index 000000000000..b96e5f50204b --- /dev/null +++ b/src/types/onyx/OptimisticAgentAccountIDMapping.ts @@ -0,0 +1,7 @@ +/** + * Maps an agent's optimistic (client-generated) accountID to the real accountID the backend assigns once CreateAgent resolves. + * Backend-populated via CreateAgent's onyxData; persisted so the optimistic accountID still resolves after a reload. + */ +type OptimisticAgentAccountIDMapping = Record; + +export default OptimisticAgentAccountIDMapping; diff --git a/src/types/onyx/OptimisticAgentAccountIDMappingCreatedAt.ts b/src/types/onyx/OptimisticAgentAccountIDMappingCreatedAt.ts new file mode 100644 index 000000000000..23b2fa5444b7 --- /dev/null +++ b/src/types/onyx/OptimisticAgentAccountIDMappingCreatedAt.ts @@ -0,0 +1,7 @@ +/** + * Records when each OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING entry was written locally, in ms since epoch (Date.now()). + * Client-only — the backend doesn't send a timestamp — so stale mapping entries can be pruned. + */ +type OptimisticAgentAccountIDMappingCreatedAt = Record; + +export default OptimisticAgentAccountIDMappingCreatedAt; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 88fd9fe74a9c..9d882f3a3f06 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -58,6 +58,7 @@ import type { CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, + OptimisticAgentAccountIDMappingEntriesDerivedValue, OutstandingReportsByPolicyIDDerivedValue, PersonalAndWorkspaceCardListDerivedValue, ReportAttributesDerivedValue, @@ -118,6 +119,8 @@ import type Onboarding from './Onboarding'; import type OnboardingRHPVariant from './OnboardingRHPVariant'; import type OnyxInputOrEntry from './OnyxInputOrEntry'; import type {AnyOnyxUpdatesFromServer, OnyxUpdateEvent, OnyxUpdatesFromServer} from './OnyxUpdatesFromServer'; +import type OptimisticAgentAccountIDMapping from './OptimisticAgentAccountIDMapping'; +import type OptimisticAgentAccountIDMappingCreatedAt from './OptimisticAgentAccountIDMappingCreatedAt'; import type {DecisionName, OriginalMessageIOU} from './OriginalMessage'; import type Pages from './Pages'; import type PendingConciergeResponse from './PendingConciergeResponse'; @@ -288,6 +291,8 @@ export type { OnyxUpdateEvent, OnyxUpdatesFromServer, AnyOnyxUpdatesFromServer, + OptimisticAgentAccountIDMapping, + OptimisticAgentAccountIDMappingCreatedAt, OdometerDraft, Pages, ConciergePendingFollowupList, @@ -426,6 +431,7 @@ export type { PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, + OptimisticAgentAccountIDMappingEntriesDerivedValue, ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, diff --git a/tests/unit/AgentActionTest.ts b/tests/unit/AgentActionTest.ts index 1b6df9bdc8e9..f96525059c86 100644 --- a/tests/unit/AgentActionTest.ts +++ b/tests/unit/AgentActionTest.ts @@ -1,9 +1,9 @@ -import {write} from '@libs/API'; -import {WRITE_COMMANDS} from '@libs/API/types'; +import {read, write} from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import Navigation from '@libs/Navigation/Navigation'; import {isRecord} from '@libs/ObjectUtils'; -import {clearAgentAvatarUpdateError, clearAgentUpdateError, createAgent, deleteAgent, updateAgentAvatar, updateAgentName, updateAgentPrompt} from '@userActions/Agent'; +import {clearAgentAvatarUpdateError, clearAgentUpdateError, createAgent, deleteAgent, openAgentsPage, updateAgentAvatar, updateAgentName, updateAgentPrompt} from '@userActions/Agent'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,11 +16,13 @@ import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import createMock from '../utils/createMock'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@libs/API'); jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn(), goBack: jest.fn()})); const mockWrite = jest.mocked(write); +const mockRead = jest.mocked(read); const mockGoBack = jest.mocked(Navigation.goBack); type CapturedUpdate = Omit, 'value'> & {value?: unknown}; @@ -50,6 +52,28 @@ function getWriteOptions(): WriteOptions { }; } +type ReadOptions = {optimisticData: CapturedUpdate[]; finallyData: CapturedUpdate[]}; + +function getReadOptions(): ReadOptions { + const options = mockRead.mock.calls.at(0)?.at(2); + if (!options || typeof options !== 'object' || !('optimisticData' in options)) { + throw new Error('read was not called with onyx options'); + } + + const {optimisticData, finallyData} = options; + if (optimisticData !== undefined && !Array.isArray(optimisticData)) { + throw new Error('optimisticData was not an update collection'); + } + if (finallyData !== undefined && !Array.isArray(finallyData)) { + throw new Error('finallyData was not an update collection'); + } + + return { + optimisticData: optimisticData ?? [], + finallyData: finallyData ?? [], + }; +} + function findUpdate(updates: CapturedUpdate[], key: OnyxKey): CapturedUpdate | undefined { return updates.find((update) => update.key === key); } @@ -79,10 +103,18 @@ function getOptimisticAccountID(optimisticData: CapturedUpdate[]): number { const OWNER_ACCOUNT_ID = 999; const OWNER_LOGIN = 'owner@test.com'; +const STALE_OPTIMISTIC_ACCOUNT_ID = 111; +const FRESH_OPTIMISTIC_ACCOUNT_ID = 222; + +beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); +}); describe('createAgent', () => { - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, null); + await waitForBatchedUpdates(); }); it('calls write with CREATE_AGENT command and provided params', () => { @@ -366,6 +398,112 @@ describe('createAgent', () => { }); expect(promptValue.errors).toBeTruthy(); }); + + it('stamps a createdAt timestamp for the new mapping entry in successData', () => { + const before = Date.now(); + const result = createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN); + const after = Date.now(); + + const {successData} = getWriteOptions(); + const createdAtValue = getUpdateRecord(successData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT)[result.optimisticAccountID]; + + expect(createdAtValue).toBeGreaterThanOrEqual(before); + expect(createdAtValue).toBeLessThanOrEqual(after); + }); + + it('prunes mapping entries older than 30 days, keeping fresher ones', async () => { + const now = Date.now(); + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, { + [STALE_OPTIMISTIC_ACCOUNT_ID]: now - 31 * 24 * 60 * 60 * 1000, + [FRESH_OPTIMISTIC_ACCOUNT_ID]: now - 1 * 24 * 60 * 60 * 1000, + }); + await waitForBatchedUpdates(); + + createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN); + + const {optimisticData} = getWriteOptions(); + const mappingPrune = getUpdateRecord(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING); + const timestampPrune = getUpdateRecord(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT); + + expect(mappingPrune[STALE_OPTIMISTIC_ACCOUNT_ID]).toBeNull(); + expect(mappingPrune[FRESH_OPTIMISTIC_ACCOUNT_ID]).toBeUndefined(); + expect(timestampPrune[STALE_OPTIMISTIC_ACCOUNT_ID]).toBeNull(); + expect(timestampPrune[FRESH_OPTIMISTIC_ACCOUNT_ID]).toBeUndefined(); + }); + + it('does not write mapping prune updates when nothing is stale', async () => { + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, {[FRESH_OPTIMISTIC_ACCOUNT_ID]: Date.now() - 24 * 60 * 60 * 1000}); + await waitForBatchedUpdates(); + + createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN); + + const {optimisticData} = getWriteOptions(); + expect(findUpdate(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING)).toBeUndefined(); + }); + + it('does not write mapping prune updates when no existing timestamps are passed', () => { + createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN); + + const {optimisticData} = getWriteOptions(); + expect(findUpdate(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING)).toBeUndefined(); + }); +}); + +describe('openAgentsPage', () => { + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, null); + await waitForBatchedUpdates(); + }); + + it('calls read with OPEN_AGENTS_PAGE command', () => { + openAgentsPage(); + + expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.OPEN_AGENTS_PAGE, null, expect.any(Object)); + }); + + it('sets ARE_AGENTS_LOADED to true in finallyData', () => { + openAgentsPage(); + + const {finallyData} = getReadOptions(); + expect(findUpdate(finallyData, ONYXKEYS.ARE_AGENTS_LOADED)?.value).toBe(true); + }); + + it('prunes mapping entries older than 30 days, keeping fresher ones', async () => { + const now = Date.now(); + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, { + [STALE_OPTIMISTIC_ACCOUNT_ID]: now - 31 * 24 * 60 * 60 * 1000, + [FRESH_OPTIMISTIC_ACCOUNT_ID]: now - 1 * 24 * 60 * 60 * 1000, + }); + await waitForBatchedUpdates(); + + openAgentsPage(); + + const {optimisticData} = getReadOptions(); + const mappingPrune = getUpdateRecord(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING); + const timestampPrune = getUpdateRecord(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT); + + expect(mappingPrune[STALE_OPTIMISTIC_ACCOUNT_ID]).toBeNull(); + expect(mappingPrune[FRESH_OPTIMISTIC_ACCOUNT_ID]).toBeUndefined(); + expect(timestampPrune[STALE_OPTIMISTIC_ACCOUNT_ID]).toBeNull(); + }); + + it('does not write mapping prune updates when nothing is stale', async () => { + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT, {[FRESH_OPTIMISTIC_ACCOUNT_ID]: Date.now() - 24 * 60 * 60 * 1000}); + await waitForBatchedUpdates(); + + openAgentsPage(); + + const {optimisticData} = getReadOptions(); + expect(findUpdate(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING)).toBeUndefined(); + }); + + it('does not write mapping prune updates when no existing timestamps are passed', () => { + openAgentsPage(); + + const {optimisticData} = getReadOptions(); + expect(findUpdate(optimisticData, ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING)).toBeUndefined(); + }); }); const TEST_ACCOUNT_ID = 42; diff --git a/tests/unit/OnyxDerived/optimisticAgentAccountIDMappingEntriesTest.ts b/tests/unit/OnyxDerived/optimisticAgentAccountIDMappingEntriesTest.ts new file mode 100644 index 000000000000..fbce74ad568b --- /dev/null +++ b/tests/unit/OnyxDerived/optimisticAgentAccountIDMappingEntriesTest.ts @@ -0,0 +1,30 @@ +import optimisticAgentAccountIDMappingEntriesConfig from '@libs/actions/OnyxDerived/configs/optimisticAgentAccountIDMappingEntries'; + +describe('optimisticAgentAccountIDMappingEntries', () => { + const optimisticAccountID = '111'; + const realAccountID = 222; + const createdAt = 1700000000000; + + it('returns an empty object when there is no mapping', () => { + expect(optimisticAgentAccountIDMappingEntriesConfig.compute([undefined, undefined], {})).toEqual({}); + }); + + it('combines a mapping entry with its createdAt timestamp', () => { + const result = optimisticAgentAccountIDMappingEntriesConfig.compute([{[optimisticAccountID]: realAccountID}, {[optimisticAccountID]: createdAt}], {}); + + expect(result).toEqual({[optimisticAccountID]: {realAccountID, createdAt}}); + }); + + it('leaves createdAt undefined when the timestamp has not arrived yet', () => { + const result = optimisticAgentAccountIDMappingEntriesConfig.compute([{[optimisticAccountID]: realAccountID}, undefined], {}); + + expect(result).toEqual({[optimisticAccountID]: {realAccountID, createdAt: undefined}}); + }); + + it('ignores a createdAt entry for an accountID with no mapping entry', () => { + const otherOptimisticAccountID = '999'; + const result = optimisticAgentAccountIDMappingEntriesConfig.compute([{[optimisticAccountID]: realAccountID}, {[otherOptimisticAccountID]: createdAt}], {}); + + expect(result).toEqual({[optimisticAccountID]: {realAccountID, createdAt: undefined}}); + }); +}); diff --git a/tests/unit/hooks/useResolvedAgentAccountID.test.ts b/tests/unit/hooks/useResolvedAgentAccountID.test.ts new file mode 100644 index 000000000000..9274e474ddbf --- /dev/null +++ b/tests/unit/hooks/useResolvedAgentAccountID.test.ts @@ -0,0 +1,127 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import useResolvedAgentAccountID from '@hooks/useResolvedAgentAccountID'; + +import initOnyxDerivedValues from '@userActions/OnyxDerived'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; +import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const OPTIMISTIC_ACCOUNT_ID = 111; +const REAL_ACCOUNT_ID = 222; +const OTHER_OPTIMISTIC_ACCOUNT_ID = 999; +const OTHER_REAL_ACCOUNT_ID = 888; + +function getMappingCreatedAt(): Promise | undefined> { + return OnyxUtils.get(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT); +} + +describe('useResolvedAgentAccountID', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + initOnyxDerivedValues(); + return waitForBatchedUpdates(); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('returns the route accountID when there is no mapping entry', async () => { + const {result} = renderHook(() => useResolvedAgentAccountID(OPTIMISTIC_ACCOUNT_ID)); + + await waitFor(() => { + expect(result.current).toEqual([OPTIMISTIC_ACCOUNT_ID, true]); + }); + }); + + it('resolves to the real accountID when a mapping entry exists', async () => { + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[OPTIMISTIC_ACCOUNT_ID]: REAL_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useResolvedAgentAccountID(OPTIMISTIC_ACCOUNT_ID)); + + await waitFor(() => { + expect(result.current).toEqual([REAL_ACCOUNT_ID, true]); + }); + }); + + it('does not resolve when the mapping has entries for other accountIDs only', async () => { + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[OTHER_OPTIMISTIC_ACCOUNT_ID]: OTHER_REAL_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useResolvedAgentAccountID(OPTIMISTIC_ACCOUNT_ID)); + + await waitFor(() => { + expect(result.current).toEqual([OPTIMISTIC_ACCOUNT_ID, true]); + }); + }); + + it('updates reactively once the mapping arrives after the hook has already mounted', async () => { + const {result} = renderHook(() => useResolvedAgentAccountID(OPTIMISTIC_ACCOUNT_ID)); + + await waitFor(() => { + expect(result.current).toEqual([OPTIMISTIC_ACCOUNT_ID, true]); + }); + + await Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[OPTIMISTIC_ACCOUNT_ID]: REAL_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + await waitFor(() => { + expect(result.current).toEqual([REAL_ACCOUNT_ID, true]); + }); + }); + + it('backfills a createdAt timestamp when a mapping entry has none, so it stays eligible for pruning', async () => { + const before = Date.now(); + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[OPTIMISTIC_ACCOUNT_ID]: REAL_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + renderHook(() => useResolvedAgentAccountID(OPTIMISTIC_ACCOUNT_ID)); + await waitForBatchedUpdates(); + const after = Date.now(); + + const createdAtMapping = await getMappingCreatedAt(); + const createdAtValue = createdAtMapping?.[OPTIMISTIC_ACCOUNT_ID]; + expect(createdAtValue).toBeGreaterThanOrEqual(before); + expect(createdAtValue).toBeLessThanOrEqual(after); + }); + + it('resolves and reports loaded from the raw mapping even when the derived entries value has not caught up yet', async () => { + // Simulates the derived engine's compute lagging behind the raw key on a cold cache (see the comment on + // the hook) — resolution must not depend on OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES being current. + await Onyx.set(ONYXKEYS.DERIVED.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_ENTRIES, {}); + await Onyx.set(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[OPTIMISTIC_ACCOUNT_ID]: REAL_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useResolvedAgentAccountID(OPTIMISTIC_ACCOUNT_ID)); + + await waitFor(() => { + expect(result.current).toEqual([REAL_ACCOUNT_ID, true]); + }); + }); + + it('does not overwrite an existing createdAt timestamp', async () => { + const originalCreatedAt = Date.now() - 24 * 60 * 60 * 1000; + await Onyx.multiSet({ + [ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING]: {[OPTIMISTIC_ACCOUNT_ID]: REAL_ACCOUNT_ID}, + [ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING_CREATED_AT]: {[OPTIMISTIC_ACCOUNT_ID]: originalCreatedAt}, + }); + await waitForBatchedUpdates(); + + renderHook(() => useResolvedAgentAccountID(OPTIMISTIC_ACCOUNT_ID)); + await waitForBatchedUpdates(); + + const createdAtMapping = await getMappingCreatedAt(); + expect(createdAtMapping?.[OPTIMISTIC_ACCOUNT_ID]).toBe(originalCreatedAt); + }); +});