Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
033019d
Revert "Merge pull request #85767 from margelo/@chrispader/dowgrade-o…
VickyStash Mar 20, 2026
1ecf384
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Mar 20, 2026
d20ea68
Re-run checks
VickyStash Mar 20, 2026
0469bf4
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Mar 20, 2026
f3dcbd4
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Mar 20, 2026
df0c9df
Remove outdated comment
VickyStash Mar 20, 2026
b1f7458
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Mar 23, 2026
141c5f5
Test onyx PR
VickyStash Mar 23, 2026
883bbba
Test onyx PR
VickyStash Mar 24, 2026
de38729
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Mar 24, 2026
cd7121e
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
fabioh8010 Mar 27, 2026
618f157
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Mar 30, 2026
55f88ce
Test onyx updates
VickyStash Mar 30, 2026
c9d3996
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Apr 16, 2026
6083a9c
Bump Onyx to 3.0.61
VickyStash Apr 16, 2026
4daa23d
Update patch to v3.0.61
VickyStash Apr 16, 2026
e0583c3
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Apr 16, 2026
3d33800
Remove extra waitForBatchedUpdates
VickyStash Apr 17, 2026
d2aba26
Merge branch 'main' into VickyStash/refactor/82871-bump-onyx-3.0.46
VickyStash Apr 20, 2026
37bb95f
Simplify the code
VickyStash Apr 20, 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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
"react-native-localize": "^3.5.4",
"react-native-nitro-modules": "0.29.4",
"react-native-nitro-sqlite": "9.2.0",
"react-native-onyx": "3.0.60",
"react-native-onyx": "3.0.61",
"react-native-pager-view": "8.0.0",
"react-native-pdf": "7.0.2",
"react-native-permissions": "^5.4.0",
Expand Down
2 changes: 1 addition & 1 deletion patches/react-native-onyx/details.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `react-native-onyx` patches

### [react-native-onyx+3.0.60.patch](react-native-onyx+3.0.60.patch)
### [react-native-onyx+3.0.61.patch](react-native-onyx+3.0.61.patch)

- Reason: Onyx v3.0.59 ([PR #756](https://github.com/Expensify/react-native-onyx/pull/756)) added a state reset inside the `subscribe` callback of `useOnyx` to fix stale data when keys change dynamically. However, this reset runs unconditionally — including on initial mount — which causes `useSyncExternalStore` to see a new snapshot reference after subscription, triggering one extra render per `useOnyx` hook. This patch guards the reset with a `hasMountedRef` flag so it only runs on key-change re-subscriptions, not on initial mount.
- E/App issue: https://github.com/Expensify/App/issues/85416
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {useFocusEffect} from '@react-navigation/native';
import {useIsFocused} from '@react-navigation/native';
import {FlashList} from '@shopify/flash-list';
import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list';
import React, {useCallback, useDeferredValue, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useDeferredValue, useEffect, useEffectEvent, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {ViewToken} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
Expand Down Expand Up @@ -375,31 +375,39 @@ function MoneyRequestReportPreviewContent({
carouselTransactionsRef.current = carouselTransactions;
}, [carouselTransactions]);

useFocusEffect(
useCallback(() => {
const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.has(transaction.transactionID));
const isFocused = useIsFocused();
const getIsFocused = useEffectEvent(() => {
return isFocused;
});

useEffect(() => {
Comment thread
mountiny marked this conversation as resolved.
const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.has(transaction.transactionID));

if (index < 0) {
if (index < 0) {
return;
}
const newTransaction = carouselTransactions.at(index);
setTimeout(() => {
if (!getIsFocused()) {
return;
}
const newTransaction = carouselTransactions.at(index);
setTimeout(() => {
// If the new transaction is not available at the index it was on before the delay, avoid the scrolling
// because we are scrolling to either a wrong or unavailable transaction (which can cause crash).
if (newTransaction?.transactionID !== carouselTransactionsRef.current.at(index)?.transactionID) {
return;
}

carouselRef.current?.scrollToIndex({
index,
viewOffset: -2 * styles.gap2.gap,
animated: true,
});
}, CONST.ANIMATED_TRANSITION);

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newTransactionIDs]),
);

// If the new transaction is not available at the index it was on before the delay, avoid the scrolling
// because we are scrolling to either a wrong or unavailable transaction (which can cause crash).
if (newTransaction?.transactionID !== carouselTransactionsRef.current.at(index)?.transactionID) {
return;
}

carouselRef.current?.scrollToIndex({
index,
viewOffset: -2 * styles.gap2.gap,
animated: true,
});
}, CONST.ANIMATED_TRANSITION);

// We only want to scroll to a new transaction when the set of new transaction IDs changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newTransactionIDs]);

const onViewableItemsChanged = useRef(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
const newIndex = viewableItems.at(0)?.index;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {useIsFocused} from '@react-navigation/native';
import type {ListRenderItem} from '@shopify/flash-list';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import type {LayoutChangeEvent} from 'react-native';
Expand Down Expand Up @@ -123,9 +122,7 @@ function MoneyRequestReportPreview({
selector: hasOnceLoadedReportActionsSelector,
});
const newTransactions = useNewTransactions(hasOnceLoadedReportActions, transactions);
const isFocused = useIsFocused();
// We only want to highlight the new expenses if the screen is focused.
const newTransactionIDs = isFocused ? new Set(newTransactions.map((transaction) => transaction.transactionID)) : undefined;
const newTransactionIDs = new Set(newTransactions.map((transaction) => transaction.transactionID));

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 Gate new transaction highlighting by screen focus

newTransactionIDs is now created unconditionally, so TransactionPreview receives shouldHighlight=true even when the report screen is blurred but still mounted in the navigation stack. In that state the highlight animation can run off-screen and finish before the user returns, which means users can miss the “new expense” highlight entirely; this also contradicts the surrounding intent comment about only highlighting when focused. Keep the new scroll-timing fix, but reintroduce focus-gating for highlight IDs passed to preview items.

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.

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.

In that state the highlight animation can run off-screen and finish before the user returns, which means users can miss the “new expense” highlight entirely

This is also mentioned in the Test steps (step 5), it highlights correctly on all of the platforms


const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle];

Expand Down
9 changes: 6 additions & 3 deletions src/pages/workspace/withPolicy.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {ComponentType} from 'react';
import React from 'react';
import React, {useEffect} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import useOnyx from '@hooks/useOnyx';
import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types';
Expand Down Expand Up @@ -94,9 +94,12 @@ export default function <TProps extends WithPolicyProps>(WrappedComponent: Compo
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */
const isLoadingPolicy = !hasLoadedApp || (!!policyID && isLoadingOnyxValue(policyResults, policyDraftResults));

if (policyID && policyID.length > 0) {
useEffect(() => {
if (!policyID) {
return;
}
updateLastAccessedWorkspace(policyID);
}
}, [policyID]);

return (
<WrappedComponent
Expand Down
25 changes: 12 additions & 13 deletions tests/unit/OptionsListUtilsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3449,7 +3449,7 @@ describe('OptionsListUtils', () => {
expect(canCreate).toBe(false);
});

it('createOptionList() localization', () => {
it('createOptionList() localization', async () => {
renderLocaleContextProvider();
// Given a set of reports and personal details
// When we call createOptionList and extract the reports
Expand All @@ -3458,18 +3458,15 @@ describe('OptionsListUtils', () => {
// Then the returned reports should match the expected values
expect(reports.at(10)?.subtitle).toBe(`Submits to Mister Fantastic`);

return (
waitForBatchedUpdates()
// When we set the preferred locale to Spanish
.then(() => Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES))
.then(() => {
// When we call createOptionList again
const newReports = createOptionList(PERSONAL_DETAILS, EMPTY_PRIVATE_IS_ARCHIVED_MAP, REPORTS, undefined).reports;
// Then the returned reports should change to Spanish
// cspell:disable-next-line
expect(newReports.at(10)?.subtitle).toBe('Se envía a Mister Fantastic');
})
);
await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES);

await waitForBatchedUpdates();

// When we call createOptionList again
const newReports = createOptionList(PERSONAL_DETAILS, EMPTY_PRIVATE_IS_ARCHIVED_MAP, REPORTS, undefined).reports;
// Then the returned reports should change to Spanish
// cspell:disable-next-line
expect(newReports.at(10)?.subtitle).toBe('Se envía a Mister Fantastic');
});
});

Expand Down Expand Up @@ -3543,6 +3540,8 @@ describe('OptionsListUtils', () => {
'1': getFakeAdvancedReportAction(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT),
},
});
await waitForBatchedUpdates();

// When we call createOptionList with report 10 marked as archived
const archivedMap: PrivateIsArchivedMap = {
[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}10`]: !!reportNameValuePairs.private_isArchived,
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/ReportSecondaryActionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,16 @@ describe('getSecondaryAction', () => {
beforeAll(() => {
Onyx.init({
keys: ONYXKEYS,
initialKeyStates: {
[ONYXKEYS.SESSION]: SESSION,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: {[EMPLOYEE_ACCOUNT_ID]: PERSONAL_DETAILS, [APPROVER_ACCOUNT_ID]: {accountID: APPROVER_ACCOUNT_ID, login: APPROVER_EMAIL}},
},
});
});

beforeEach(async () => {
jest.clearAllMocks();
Onyx.clear();
await Onyx.merge(ONYXKEYS.SESSION, SESSION);
await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[EMPLOYEE_ACCOUNT_ID]: PERSONAL_DETAILS, [APPROVER_ACCOUNT_ID]: {accountID: APPROVER_ACCOUNT_ID, login: APPROVER_EMAIL}});
});

it('should always return default options', () => {
Expand Down
21 changes: 14 additions & 7 deletions tests/unit/SequentialQueueTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ describe('SequentialQueue', () => {
};
SequentialQueue.push(requestWithConflictResolution);
expect(getLength()).toBe(1);
// We know there is only one request in the queue, so we can get the first one and verify
// that the persisted request is the second one.
const persistedRequest = getAll().at(0);
expect(persistedRequest?.data?.accountID).toBe(56789);
// We know there is only one request and it's ongoing.
// We can get it and verify that the ongoing request is the second one.
const ongoingRequest = getOngoingRequest();
expect(ongoingRequest?.data?.accountID).toBe(56789);
});

it('should push two requests with conflict resolution and push', () => {
Expand Down Expand Up @@ -109,7 +109,9 @@ describe('SequentialQueue', () => {
};

SequentialQueue.push(requestWithConflictResolution);
expect(getLength()).toBe(2);

const ongoingRequest = getOngoingRequest();
expect(ongoingRequest?.data?.accountID).toBe(56789);
});

it('should replace request request in queue while a similar one is ongoing', async () => {
Expand Down Expand Up @@ -175,9 +177,14 @@ describe('SequentialQueue', () => {

expect(getLength()).toBe(4);
const persistedRequests = getAll();
// We know ReconnectApp is at index 1 in the queue, so we can get it to verify
const ongoingRequest = getOngoingRequest();

// The first OpenReport call is ongoing
expect(ongoingRequest?.command).toBe('OpenReport');

// We know ReconnectApp is at index 0 in the queue now, so we can get it to verify
// that was replaced by the new request.
expect(persistedRequests.at(1)?.data?.accountID).toBe(56789);
expect(persistedRequests.at(0)?.data?.accountID).toBe(56789);
});

// need to test a rance condition between processing the next request and then pushing a new request with conflict resolver
Expand Down
Loading