Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
49cca88
Confirm new expenses with a “View” toast instead of scrolling to and …
mhawryluk Jul 20, 2026
066854f
Merge branch 'main' into declarative-expense-added-growl
mhawryluk Jul 20, 2026
956d2a2
Fix tests
mhawryluk Jul 20, 2026
b3e03b2
Invoke findSelfDMReportID only when a transaction is active in Expen…
mhawryluk Jul 21, 2026
a9be08a
Add comment for latestTransactionID last-created assumption
mhawryluk Jul 21, 2026
62ee8e8
Clear EXPENSE_ADDED_GROWL_TRANSACTION_IDS on new session
mhawryluk Jul 21, 2026
e53cdd3
Add key to ensure remounting GrowlNotificationContent in ExpenseAdded…
mhawryluk Jul 21, 2026
ac42067
Remove mergeExpenseAddedGrowlTransactionIDs call from navigateAfterEx…
mhawryluk Jul 21, 2026
698268c
Remove useAnimatedHighlightStyle usage from Spend table
mhawryluk Jul 21, 2026
e863597
Merge branch 'main' into declarative-expense-added-growl
mhawryluk Jul 21, 2026
ca1593a
Remove unused handleNavigateAfterExpenseCreate
mhawryluk Jul 21, 2026
569cb42
Fix not showing growl when submitting photo from gallery
mhawryluk Jul 23, 2026
2ab5a2c
Merge branch 'main' into declarative-expense-added-growl
mhawryluk Jul 24, 2026
c0c93d4
Fix after merge
mhawryluk Jul 24, 2026
62419b6
Fix after merge 2
mhawryluk Jul 24, 2026
cb74505
Rename files from useSearchHighlightAndScroll to useSearchAutoRefetch
mhawryluk Jul 28, 2026
3dc7dd2
Add jsdoc to UseSearchAutoRefetch
mhawryluk Jul 28, 2026
13d868f
Separate ExpenseAddedGrowlContent to remove onyx connections when no …
mhawryluk Jul 28, 2026
8d64539
Derive backTo from the existing RHP's own fallback route when forceRe…
mhawryluk Jul 28, 2026
42fb7e7
Add jsdoc to NavigateAfterExpenseCreateParams
mhawryluk Jul 28, 2026
5e8f6bc
Add a comment about surfacing only the latest transaction id from a b…
mhawryluk Jul 28, 2026
8a9d087
Update outdated ExpenseFlatSearchView comment
mhawryluk Jul 28, 2026
8db62fd
Merge branch 'main' into declarative-expense-added-growl
mhawryluk Jul 28, 2026
67a4612
Fix after merge
mhawryluk Jul 28, 2026
83ee6a4
Merge branch 'main' into declarative-expense-added-growl
mhawryluk Jul 29, 2026
57509e3
Merge branch 'main' into declarative-expense-added-growl
mhawryluk Jul 30, 2026
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
4 changes: 2 additions & 2 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -570,8 +570,8 @@
"../../src/hooks/useRestoreWorkspacesTabOnNavigate.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1
"../../src/hooks/useSearchAutoRefetch.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
"../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1
Expand Down Expand Up @@ -2093,7 +2093,7 @@
"../../tests/unit/useReceiptHoverZoomTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../tests/unit/useReportActionAvatarsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/useReportUnreadMessageScrollTrackingTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/useSearchHighlightAndScrollTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../tests/unit/useSearchAutoRefetchTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../tests/unit/useSearchSelectorTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5
"../../tests/unit/useShareSavedSearchTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/useSidebarOrderedReportsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
Expand Down
2 changes: 2 additions & 0 deletions src/GlobalModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, {startTransition, useEffect, useState} from 'react';

import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
import ExpenseAddedGrowl from './components/ExpenseAddedGrowl';
import GrowlNotification from './components/GrowlNotification';
import LazyModalSlot from './components/LazyModalSlot';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
Expand Down Expand Up @@ -50,6 +51,7 @@ function GlobalModals() {
return (
<>
<GrowlNotification ref={growlRef} />
<ExpenseAddedGrowl />
Comment on lines 53 to +54

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 Coordinate the expense toast with the existing growl host

This mounts a second, independently managed GrowlNotificationContent alongside the existing singleton growl. On narrow layouts both use the same fixed top anchor, so if an existing Growl.error() is shown during the expense toast—for example from the tab-navigation error path—the two notifications overlap and neither replaces or dismisses the other, obscuring text and touch targets. Route this notification through the shared growl host or otherwise serialize the two sources.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

it's technically true, but in practice this will not happen currently - we very rarely show the imperative growl errors, and not when creating new expenses. and I don't think it's worth adding synchronization of the two growl mechanisms at this moment, I think this can be handled if we do end up using growls in more scenarios, which would cause the overlap. right now it would complicate the code and fix an issue that is not present and might never be

<DelegateNoAccessModalProvider>
{shouldRenderContextMenu && (
<LazyModalSlot>
Expand Down
7 changes: 4 additions & 3 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {Attendee, DistanceExpenseType, Participant} from './types/onyx/IOU'
import type Onboarding from './types/onyx/Onboarding';
import type {AnyOnyxUpdate} from './types/onyx/Request';
import type {SavedCSVColumnLayoutList} from './types/onyx/SavedCSVColumnLayout';
import type {SearchDataTypes} from './types/onyx/SearchResults';
import type AssertTypesEqual from './types/utils/AssertTypesEqual';
import type DeepValueOf from './types/utils/DeepValueOf';

Expand Down Expand Up @@ -836,8 +837,8 @@ const ONYXKEYS = {
/** Whether the user has denied the contact import permission prompt */
HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt',

/** The transaction IDs to be highlighted when opening the Expenses search route page */
TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute',
/** Maps each newly-added transaction ID to its search data type, flagging it for the "Expense added" growl */
EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs',

/** The report ID to be highlighted when returning to the workspace rooms page */
ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage',
Expand Down Expand Up @@ -1797,7 +1798,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
[ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record<string, Record<string, boolean>>;
[ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record<string, SearchDataTypes>;
[ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null;
[ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined;
};
Expand Down
178 changes: 178 additions & 0 deletions src/components/ExpenseAddedGrowl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';

import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report';
import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction';
import Log from '@libs/Log';
import {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
import Navigation from '@libs/Navigation/Navigation';
import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';

import type {Dispatch, SetStateAction} from 'react';

import {useEffect, useRef, useState} from 'react';

import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent';

type ActiveGrowl = {
/** ID of the transaction the growl was raised for */
transactionID: string;

/** Search data type the growl belongs to (expense, invoice, etc.) */
dataType: SearchDataTypes;

/** Identifies each growl instance so a stale slide-out dismissal can't clear a newer growl */
nonce: number;
};

type ExpenseAddedGrowlContentProps = {
/** Transaction to read for the growl: the active growl's transaction, or the latest pending signal ID */
transactionID: string;

/** Pending expense-added notifications, keyed by transaction ID with its search data type as the value */
signal: Record<string, SearchDataTypes> | undefined;

/** Currently displayed growl */
active: ActiveGrowl | null;

/** Setter for the active growl */
setActive: Dispatch<SetStateAction<ActiveGrowl | null>>;
};

/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */
function ExpenseAddedGrowl() {
const [active, setActive] = useState<ActiveGrowl | null>(null);
const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1);

if (!transactionID) {
return null;
}

return (
<ExpenseAddedGrowlContent
transactionID={transactionID}
signal={signal}
active={active}
setActive={setActive}
/>
);
}

/**
* Reads the candidate transaction and its report/actions and renders the growl. Split out from
* `ExpenseAddedGrowl` so these Onyx connections only exist while there is a pending signal or a visible growl -
* the outer component is mounted for the whole app lifetime and would otherwise hold them permanently while idle.
*/
function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) {
const nonceRef = useRef(0);

const {translate} = useLocalize();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
const reportID = transaction?.reportID;
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
// A tracked/unreported expense's transaction.reportID is UNREPORTED_REPORT_ID, and its IOU/track action lives
// on the self-DM report - not on transaction.reportID. Read actions from the report that actually hosts the
// action so "View" resolves the real transaction thread (the action's childReportID) instead of fabricating
// a mismatched optimistic one.
const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined;
const hostReportID = isUnreportedExpense ? selfDMReportID : reportID;
const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`);
// Only IOU/expense/invoice reports have the expense-report RHP that "View" opens. A tracked/unreported
// (self-DM) expense lives in a chat, so it has no such report - leaving iouReportID undefined makes
// navigateToCreatedExpense open the transaction thread directly instead of a super-wide report RHP it lacks.
const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined;
const iouReportID = iouReport?.reportID;

useEffect(() => {
Comment thread
mhawryluk marked this conversation as resolved.
if (active) {
return;
}
const pendingTransactionIDs = Object.keys(signal ?? {});
// The last key is the last-created transaction: IDs are large (rand64), so they exceed the array-index
// range and JS preserves insertion order rather than sorting them numerically ascending.
const latestTransactionID = pendingTransactionIDs.at(-1);
Comment thread
mhawryluk marked this conversation as resolved.
const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined;
if (!latestTransactionID || !dataType) {
return;
}
// Wait for the created expense's optimistic data to land before acting. The create's API.write is
// usually deferred (the deferred-for-search pattern) until Search's onLayout flushes it, and the whole
// optimistic dataset (transaction + IOU report/action + thread) is applied atomically - so the
// transaction appearing in Onyx means "View" can resolve AND its report is known for the check below.
if (!transaction) {
return;
}

// Clear every pending ID but surface only the latest: rapid creations that pile up before this runs
// collapse into a single growl for the newest expense.
mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null])));
Comment thread
mhawryluk marked this conversation as resolved.

// Suppress the growl when the user is already viewing the expense's money-request report (its
// transaction list), where the new row is highlighted so a growl would be redundant. A tracked/self-DM
// expense has transaction.reportID === UNREPORTED_REPORT_ID (never a report you view), so this never
// matches for those and the growl always shows - matching "no transaction list to be in".
if (Navigation.getTopmostReportId() === transaction.reportID) {
return;
}
nonceRef.current += 1;
setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current});
}, [signal, active, transaction, setActive]);

if (!active) {
return null;
}

const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE;

// Materialize the transaction thread and navigate to it at press time (not show time): the thread is
// only built if the user actually taps "View", against the freshest Onyx data, matching how every other
// thread navigation entry point builds the thread at navigation time.
const navigateToExpense = () => {
const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), active.transactionID);
let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID;
if (threadReportID) {
setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID);
} else {
const optimisticThread = createTransactionThreadReport({
introSelected,
currentUserLogin: currentUserPersonalDetails?.login ?? '',
currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
betas,
iouReport,
iouReportAction: iouAction,
transaction,
});
threadReportID = optimisticThread?.reportID;
}
if (!threadReportID) {
Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.');
return;
}
navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID});
};

return (
<GrowlNotificationContent
Comment thread
mhawryluk marked this conversation as resolved.
key={active.nonce}
nonce={active.nonce}
type={CONST.GROWL.SUCCESS}
duration={CONST.GROWL.DURATION_WITH_ACTION}
bodyText={translate(isInvoice ? 'iou.invoiceSent' : 'iou.expenseAdded')}
action={{label: translate('common.view'), onPress: navigateToExpense}}
onDismissed={(dismissedNonce) => setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))}
/>
);
}

export default ExpenseAddedGrowl;
8 changes: 7 additions & 1 deletion src/components/MoneyRequestHeaderSecondaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState';
import useTransactionViolations from '@hooks/useTransactionViolations';

import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate';
import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers';
import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense';
import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction';
import initSplitExpense from '@libs/actions/SplitExpenses';
Expand Down Expand Up @@ -243,11 +244,12 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
const optimisticIOUReportID = generateReportID();
const activePolicyCategoriesMap = defaultPolicyCategories ?? {};

let lastDuplicateTransactionID: string | undefined;
for (const item of transactions) {
const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction);
const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined;

duplicateTransactionAction({
const result = duplicateTransactionAction({
transaction: item,
optimisticChatReportID,
optimisticIOUReportID,
Expand All @@ -272,7 +274,11 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
policyTagList,
formatPhoneNumber,
});
if (result?.transactionID) {
lastDuplicateTransactionID = result.transactionID;
}
}
signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
};

const dismissModalAndUpdateUseHold = () => {
Expand Down
6 changes: 1 addition & 5 deletions src/components/Search/ChatSearchView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CONST from '@src/CONST';

import type {NativeSyntheticEvent} from 'react-native';

import React, {useImperativeHandle} from 'react';
import React from 'react';

import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
Expand Down Expand Up @@ -49,7 +49,6 @@ function ChatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
ref,
}: ChatSearchViewProps) {
const {type} = queryJSON;

Expand All @@ -65,9 +64,6 @@ function ChatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;

// Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);

const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent<ExtendedTargetedEvent>) => void) => (
// Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
<AnimatedExitRow
Expand Down
15 changes: 5 additions & 10 deletions src/components/Search/ExpenseFlatSearchView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CONST from '@src/CONST';

import type {NativeSyntheticEvent} from 'react-native';

import React, {useImperativeHandle} from 'react';
import React from 'react';

import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps';
Expand All @@ -29,11 +29,10 @@ const isRowDeleted = (item: SearchListItem) => item.pendingAction === CONST.RED_
* `toggle`/`toggleAll`/`selectedTransactions`). The shared list state and interactions come from
* `useSearchListViewState`, and the surrounding chrome (horizontal scroll, header bar, long-press menu)
* from `SearchListViewLayout`; this view only owns the flat-expense specifics: the `TransactionListItem`
* renderer, single-pass visibility/selection counts, and the highlight-scroll imperative handle.
* `TransactionListItem` is the only row renderer here and rows always animate, so the
* group/sticky/chat/task branches of `SearchList` do not apply. Keyboard navigation is inherited from
* `BaseSearchList`; the post-create highlight stays in the router (the snapshot stamps
* `shouldAnimateInHighlight`, and `newTransactions` flows into `extraData`).
* renderer and single-pass visibility/selection counts.
* `TransactionListItem` is the only row renderer here, so the group/sticky/chat/task branches of
* `SearchList` do not apply. Keyboard navigation is inherited from `BaseSearchList`; `newTransactions`
* flows into `extraData` so the list re-renders when a freshly-created expense lands.
*/
function ExpenseFlatSearchView({
queryJSON,
Expand All @@ -55,7 +54,6 @@ function ExpenseFlatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
ref,
}: ExpenseFlatSearchViewProps) {
const {type} = queryJSON;

Expand Down Expand Up @@ -89,9 +87,6 @@ function ExpenseFlatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;

// Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);

const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent<ExtendedTargetedEvent>) => void) => {
const isDisabled = isRowDeleted(item);
// Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate).
Expand Down
Loading
Loading