diff --git a/src/hooks/useIsOwnWorkspaceChatRef.ts b/src/hooks/useIsOwnWorkspaceChatRef.ts new file mode 100644 index 0000000000000..edc2eaafb9ef8 --- /dev/null +++ b/src/hooks/useIsOwnWorkspaceChatRef.ts @@ -0,0 +1,30 @@ +import {useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type * as OnyxTypes from '@src/types/onyx'; + +/** + * Returns a ref that tracks whether the currently-open report is an own workspace chat. + * + * Must be updated synchronously during render — after a vacation delegate splits an expense + * the server sends an Onyx SET that wipes the report object. By the time any useEffect fires, + * `report` is already undefined, so live state and usePrevious both fail. The ref persists + * the last known value through that wipe window so navigation guards and re-fetch effects + * can still make the correct decision. See issue #84248. + */ +function useIsOwnWorkspaceChatRef(report: OnyxEntry | undefined, reportIDFromRoute: string | undefined) { + const isOwnWorkspaceChatRef = useRef(false); + + if (report?.reportID && report.reportID === reportIDFromRoute) { + // Valid, matching report — update the ref. + isOwnWorkspaceChatRef.current = !!report.isOwnPolicyExpenseChat; + } else if (!report?.reportID) { + // Report wiped by Onyx SET — intentionally keep the last known value. + } else { + // Different report loaded — reset. + isOwnWorkspaceChatRef.current = false; + } + + return isOwnWorkspaceChatRef; +} + +export default useIsOwnWorkspaceChatRef; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 300112575f989..5ece3ad2a6c2d 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -85,6 +85,23 @@ function SidebarOrderedReportsContextProvider({ const derivedCurrentReportID = currentReportIDForTests ?? currentReportIDValue; const prevDerivedCurrentReportID = usePrevious(derivedCurrentReportID); + // Track whether the currently-open report is an own workspace chat. We use a ref set + // synchronously during render because chatReports[currentReportID] gets wiped to undefined + // when a delegate splits an expense — the live value can't be trusted at effect time. + // The ref persists through the wipe so the LHN force-inclusion still fires. See issue #84248. + const isCurrentReportOwnWorkspaceChatRef = useRef(false); + const prevCurrentReportIDForRef = useRef(undefined); + if (derivedCurrentReportID !== prevCurrentReportIDForRef.current) { + // Report changed — reset + isCurrentReportOwnWorkspaceChatRef.current = false; + prevCurrentReportIDForRef.current = derivedCurrentReportID; + } + const currentChatReportEntry = derivedCurrentReportID ? chatReports?.[`${ONYXKEYS.COLLECTION.REPORT}${derivedCurrentReportID}`] : undefined; + if (currentChatReportEntry?.reportID) { + isCurrentReportOwnWorkspaceChatRef.current = !!currentChatReportEntry.isOwnPolicyExpenseChat; + } + // else: report was wiped — intentionally keep the last known value in the ref + // we need to force reportsToDisplayInLHN to re-compute when we clear currentReportsToDisplay, but the way it currently works relies on not having currentReportsToDisplay as a memo dependency, so we just need something we can change to trigger it // I don't like it either, but clearing the cache is only a hack for the debug modal and I will endeavor to make it better as I work to improve the cache correctness of the LHN more broadly const [clearCacheDummyCounter, setClearCacheDummyCounter] = useState(0); @@ -284,12 +301,13 @@ function SidebarOrderedReportsContextProvider({ // the current report is missing from the list, which should very rarely happen. In this // case we re-generate the list a 2nd time with the current report included. - // We also execute the following logic if `shouldUseNarrowLayout` is false because this is - // requirement for web. Consider a case, where we have report with expenses and we click on - // any expense, a new LHN item is added in the list and is visible on web. But on mobile, we - // just navigate to the screen with expense details, so there seems no point to execute this logic on mobile. + // On narrow layouts (iOS/mobile) the force-inclusion block is normally skipped because + // navigating to a new report simply replaces the screen. However, when a vacation delegate + // splits an expense, a temporary server SET wipes the own workspace chat from chatReports. + // We bypass the narrow-layout skip for that specific case so the LHN stays correct. + // See issue #84248. if ( - (!shouldUseNarrowLayout || orderedReportIDs.length === 0) && + (!shouldUseNarrowLayout || orderedReportIDs.length === 0 || isCurrentReportOwnWorkspaceChatRef.current) && derivedCurrentReportID && derivedCurrentReportID !== '-1' && orderedReportIDs.indexOf(derivedCurrentReportID) === -1 diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index fa316de5a35de..54a244978fec0 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -4,6 +4,7 @@ import {InteractionManager} from 'react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useIsOwnWorkspaceChatRef from '@hooks/useIsOwnWorkspaceChatRef'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -98,6 +99,9 @@ function ReportFetchHandler() { const isTransactionThreadView = isReportTransactionThread(report); + // Track whether the current route is an own workspace chat. See issue #84248. + const isCurrentRouteOwnWorkspaceChatRef = useIsOwnWorkspaceChatRef(report, reportIDFromRoute); + const indexOfLinkedMessage = reportActionIDFromRoute ? reportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)) : -1; const doesCreatedActionExists = !!reportActions?.findLast((action) => isCreatedAction(action)); const isLinkedMessageAvailable = indexOfLinkedMessage > -1; @@ -156,6 +160,21 @@ function ReportFetchHandler() { // Effect order below matches the original declaration order in ReportScreen.tsx. + // When a delegate splits an expense the server sends a temporary Onyx SET that wipes the + // workspace chat. The navigation guards in ReportScreen block any redirect, but the report + // stays blank until something re-fetches it. This effect detects the wipe and re-fetches. + // See issue #84248. + const prevReportID = usePrevious(report?.reportID); + useEffect(() => { + const wasJustWiped = !!prevReportID && prevReportID === reportIDFromRoute && !report?.reportID; + if (!wasJustWiped || !isCurrentRouteOwnWorkspaceChatRef.current) { + return; + } + fetchReport(); + // fetchReport is a stable useEffectEvent callback and does not need to be listed as a dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [report?.reportID, prevReportID, reportIDFromRoute]); + useEffect(() => { if (!transactionThreadReportID || !route?.params?.reportActionID || !isOneTransactionThread(childReport, report, linkedAction)) { return; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 9ab0402f7c476..50d139151fa80 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -23,6 +23,7 @@ import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useIsOwnWorkspaceChatRef from '@hooks/useIsOwnWorkspaceChatRef'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import useNetwork from '@hooks/useNetwork'; import useNewTransactions from '@hooks/useNewTransactions'; @@ -308,6 +309,9 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const isReportArchived = useReportIsArchived(report?.reportID); const {isEditingDisabled} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); + // Track whether the current route is an own workspace chat. See issue #84248. + const isCurrentRouteOwnWorkspaceChatRef = useIsOwnWorkspaceChatRef(report, reportIDFromRoute); + useEffect(() => { // We don't want this effect to run on the first render. if (firstRender) { @@ -322,7 +326,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) { isEmpty(report) && (isMoneyRequest(prevReport) || isMoneyRequestReport(prevReport) || - isPolicyExpenseChat(prevReport) || + // Own policy expense chats (workspace chats) are excluded: a vacation delegate + // splitting an expense sends a temporary server SET that wipes the report, but + // the chat was never intentionally removed. See issue #84248. + (isPolicyExpenseChat(prevReport) && !prevReport?.isOwnPolicyExpenseChat) || isGroupChat(prevReport) || isAdminRoom(prevReport) || isAnnounceRoom(prevReport)); @@ -415,6 +422,47 @@ function ReportScreen({route, navigation}: ReportScreenProps) { return; } + // For own workspace chats, a vacation delegate split sends a temporary Onyx SET that + // wipes the report — triggering this effect — but the re-fetch in ReportFetchHandler + // restores the data shortly after. We delay navigation to allow the re-fetch to settle. + // If the wipe is temporary, reportWasDeleted resets to false before the timer fires and + // the delayed navigation never executes. + // If the workspace was genuinely removed (e.g. policy access revoked), the report stays + // gone after the delay and we navigate away correctly. See issue #84248. + if (isCurrentRouteOwnWorkspaceChatRef.current) { + const timer = setTimeout(() => { + // Re-check after delay: if report came back (re-fetch succeeded), skip navigation. + if (!reportWasDeleted) { + return; + } + Navigation.dismissModal(); + if (Navigation.getTopmostReportId() === reportIDFromRoute) { + Navigation.isNavigationReady().then(() => { + Navigation.popToSidebar(); + }); + } + if (deletedReportParentID && !isMoneyRequestReportPendingDeletion(deletedReportParentID)) { + Navigation.isNavigationReady().then(() => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(deletedReportParentID)); + }); + return; + } + Navigation.isNavigationReady().then(() => { + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); + }); + }, 500); + return () => clearTimeout(timer); + } + + // Clean up the navigation stack before redirecting to prevent an infinite loop where + // pressing back returns to the wiped report URL and re-triggers this effect. + Navigation.dismissModal(); + if (Navigation.getTopmostReportId() === reportIDFromRoute) { + Navigation.isNavigationReady().then(() => { + Navigation.popToSidebar(); + }); + } + // Try to navigate to parent report if available if (deletedReportParentID && !isMoneyRequestReportPendingDeletion(deletedReportParentID)) { Navigation.isNavigationReady().then(() => { @@ -427,7 +475,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { Navigation.isNavigationReady().then(() => { navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); }); - }, [reportWasDeleted, isFocused, deletedReportParentID, conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas]); + }, [reportWasDeleted, isFocused, deletedReportParentID, conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, reportIDFromRoute]); const actionListValue = useActionListContextValue();