From 05f592ad370d027f54366641f678cce9154cccf9 Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Sun, 17 May 2026 15:07:47 +0000 Subject: [PATCH 1/6] Await Onyx merge before native close in HybridApp to prevent iOS crash The closeReactNativeApp function was calling Navigation.clearPreloadedRoutes() and HybridAppModule.closeReactNativeApp() before the Onyx merge of closingReactNativeApp had resolved. This caused a race condition where React re-renders (e.g., StatusBar updates) could fire against a native surface already being torn down, leading to EXC_BAD_ACCESS crashes on iOS. Now the Onyx merge is awaited before clearing routes and calling the native module. A re-entry guard prevents double invocation during a single close sequence. Co-authored-by: Yauheni Horbach --- src/libs/actions/HybridApp/index.ts | 27 +++++++++++++++++++++---- tests/unit/HybridAppActionsTest.ts | 31 ++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/HybridApp/index.ts b/src/libs/actions/HybridApp/index.ts index deeaf24a3bc7..a7cfb1897b07 100644 --- a/src/libs/actions/HybridApp/index.ts +++ b/src/libs/actions/HybridApp/index.ts @@ -14,6 +14,7 @@ let currentSessionAccountID: Session['accountID']; let isLoadingApp = true; let isLoadingTryNewDot = true; let hasReceivedTryNewDotUpdate = false; +let isClosingReactNativeApp = false; function getSessionAccountID(session: OnyxEntry): Session['accountID'] { return session?.accountID; @@ -33,6 +34,15 @@ function updateTryNewDotLoadingState(isTryNewDotUpdate = false, isInitialTryNewD isLoadingTryNewDot = isLoadingApp !== false; } +Onyx.connectWithoutView({ + key: ONYXKEYS.HYBRID_APP, + callback: (hybridApp) => { + if (!hybridApp?.closingReactNativeApp) { + isClosingReactNativeApp = false; + } + }, +}); + Onyx.connectWithoutView({ key: ONYXKEYS.NVP_TRY_NEW_DOT, callback: (tryNewDot) => { @@ -68,6 +78,7 @@ Onyx.connectWithoutView({ currentTryNewDot = undefined; hasReceivedTryNewDotUpdate = false; + isClosingReactNativeApp = false; isLoadingTryNewDot = nextSessionAccountID !== undefined || isLoadingApp !== false; }, }); @@ -109,13 +120,21 @@ function closeReactNativeApp({shouldSetNVP, isTrackingGPS, shouldIgnoreTryNewDot return; } - Navigation.clearPreloadedRoutes(); + if (CONFIG.IS_HYBRID_APP && isClosingReactNativeApp) { + return; + } + if (CONFIG.IS_HYBRID_APP) { - Onyx.merge(ONYXKEYS.HYBRID_APP, {closingReactNativeApp: true}); + isClosingReactNativeApp = true; } - // eslint-disable-next-line no-restricted-properties - HybridAppModule.closeReactNativeApp({shouldSetNVP}); + const closingPromise = CONFIG.IS_HYBRID_APP ? Onyx.merge(ONYXKEYS.HYBRID_APP, {closingReactNativeApp: true}) : Promise.resolve(); + + closingPromise.then(() => { + Navigation.clearPreloadedRoutes(); + // eslint-disable-next-line no-restricted-properties + HybridAppModule.closeReactNativeApp({shouldSetNVP}); + }); } /* diff --git a/tests/unit/HybridAppActionsTest.ts b/tests/unit/HybridAppActionsTest.ts index 2a960283a840..c1bffd8a1b51 100644 --- a/tests/unit/HybridAppActionsTest.ts +++ b/tests/unit/HybridAppActionsTest.ts @@ -87,6 +87,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); @@ -97,6 +98,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false, shouldIgnoreTryNewDotLoading: true}); + await waitForBatchedUpdatesWithAct(); expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); @@ -138,6 +140,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); jest.clearAllMocks(); @@ -146,6 +149,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).not.toHaveBeenCalled(); await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); @@ -154,6 +158,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); @@ -173,6 +178,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); jest.clearAllMocks(); @@ -184,6 +190,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).not.toHaveBeenCalled(); await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, { @@ -194,6 +201,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); @@ -219,6 +227,7 @@ describe('HybridApp actions', () => { // closeReactNativeApp should still work because the initial session load // must not blank the already-populated currentTryNewDot closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); @@ -238,6 +247,7 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); jest.clearAllMocks(); @@ -248,13 +258,32 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); - it('preserves shouldSetNVP false exits for existing non-force-mobile flows', () => { + it('preserves shouldSetNVP false exits for existing non-force-mobile flows', async () => { closeReactNativeApp({shouldSetNVP: false, isTrackingGPS: false}); + await waitForBatchedUpdatesWithAct(); expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: false}); }); + + it('awaits Onyx merge before calling native close and clearing routes', async () => { + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await waitForBatchedUpdatesWithAct(); + + closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); + + // Before the promise resolves, neither route clearing nor native close should have been called + expect(Navigation.clearPreloadedRoutes).not.toHaveBeenCalled(); + expect(closeNativeAppSpy).not.toHaveBeenCalled(); + + await waitForBatchedUpdatesWithAct(); + + // After the promise resolves, both should have been called + expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); + expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); + }); }); From cab5be65cbe6fd6776b807c5ab15c0e7ed06f395 Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Sun, 17 May 2026 15:15:57 +0000 Subject: [PATCH 2/6] Fix ESLint prefer-early-return in HybridApp Onyx callback Co-authored-by: Yauheni Horbach --- src/libs/actions/HybridApp/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/HybridApp/index.ts b/src/libs/actions/HybridApp/index.ts index a7cfb1897b07..1feedaca1842 100644 --- a/src/libs/actions/HybridApp/index.ts +++ b/src/libs/actions/HybridApp/index.ts @@ -37,9 +37,11 @@ function updateTryNewDotLoadingState(isTryNewDotUpdate = false, isInitialTryNewD Onyx.connectWithoutView({ key: ONYXKEYS.HYBRID_APP, callback: (hybridApp) => { - if (!hybridApp?.closingReactNativeApp) { - isClosingReactNativeApp = false; + if (hybridApp?.closingReactNativeApp) { + return; } + + isClosingReactNativeApp = false; }, }); From 1410cc2ee1eb6f148c9ba86a2f547f0f8004bdb5 Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Tue, 19 May 2026 09:58:27 +0000 Subject: [PATCH 3/6] Address review comments: add eslint-disable justification and error handling - Added justification comment explaining why no-restricted-properties is disabled - Added .catch() handler to the promise chain so that if Onyx.merge rejects, the app still attempts to close rather than leaving the user stuck Co-authored-by: Yauheni Horbach --- src/libs/actions/HybridApp/index.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/HybridApp/index.ts b/src/libs/actions/HybridApp/index.ts index 1feedaca1842..84e5027a92ca 100644 --- a/src/libs/actions/HybridApp/index.ts +++ b/src/libs/actions/HybridApp/index.ts @@ -1,6 +1,7 @@ import HybridAppModule from '@expensify/react-native-hybrid-app'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {isLockedToNewApp, shouldBlockOldAppExit} from '@libs/TryNewDotUtils'; import {setIsGPSInProgressModalOpen} from '@userActions/isGPSInProgressModalOpen'; @@ -132,11 +133,20 @@ function closeReactNativeApp({shouldSetNVP, isTrackingGPS, shouldIgnoreTryNewDot const closingPromise = CONFIG.IS_HYBRID_APP ? Onyx.merge(ONYXKEYS.HYBRID_APP, {closingReactNativeApp: true}) : Promise.resolve(); - closingPromise.then(() => { - Navigation.clearPreloadedRoutes(); - // eslint-disable-next-line no-restricted-properties - HybridAppModule.closeReactNativeApp({shouldSetNVP}); - }); + closingPromise + .then(() => { + Navigation.clearPreloadedRoutes(); + // We need to call HybridAppModule.closeReactNativeApp directly as a native module method + // eslint-disable-next-line no-restricted-properties + HybridAppModule.closeReactNativeApp({shouldSetNVP}); + }) + .catch((error) => { + Log.warn('Failed to merge HYBRID_APP closing state', {error}); + // Still attempt to close the app to avoid leaving the user stuck + Navigation.clearPreloadedRoutes(); + // eslint-disable-next-line no-restricted-properties + HybridAppModule.closeReactNativeApp({shouldSetNVP}); + }); } /* From 3afba1ef5a3e66cb86dd637a99a406abcd0292e9 Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Thu, 21 May 2026 08:05:09 +0000 Subject: [PATCH 4/6] Update @shopify/react-native-skia from 2.4.14 to 2.6.2 Co-authored-by: Yauheni Horbach --- ios/Podfile.lock | 8 +++---- package-lock.json | 57 +++++++++++++++++++++++++++++++++++------------ package.json | 6 ++--- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1383bfb653f0..964da9bef75e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2849,7 +2849,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-skia (2.4.14): + - react-native-skia (2.6.2): - boost - DoubleConversion - fast_float @@ -4287,7 +4287,7 @@ DEPENDENCIES: - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - - "FullStory (from `{http: \"https://ios-releases.fullstory.com/fullstory-1.68.3-xcframework.tar.gz\"}`)" + - "FullStory (from `{:http=>\"https://ios-releases.fullstory.com/fullstory-1.68.3-xcframework.tar.gz\"}`)" - "fullstory_react-native (from `../node_modules/@fullstory/react-native`)" - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - group-ib-fp (from `../node_modules/group-ib-fp`) @@ -4808,7 +4808,7 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: b39ec807040f5a775de027a4a9647c0f4222c6ef + hermes-engine: 3b9f19b76c01aac5826b3f609b85006718bf7ecc libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -4880,7 +4880,7 @@ SPEC CHECKSUMS: react-native-pdf: 6a09a9be0e7ee954ea671437483316f9a28f8572 react-native-plaid-link-sdk: 425c0a3a923310fcd8489142209ff1508372a7bf react-native-safe-area-context: 0a3b034bb63a5b684dd2f5fffd3c90ef6ed41ee8 - react-native-skia: 51f30133876025c83e933f4f7253479e6de5d937 + react-native-skia: 1b89b2adbdb1af25528ab7b3e0b154718b9c6813 react-native-view-shot: 28aca10c6c6e5331959ba4b6cb2fced572f88af3 react-native-wallet: 4e3cc1f48ca653ad4a96df8da7e6bd9c8987b3e3 react-native-webview: cdce419e8022d0ef6f07db21890631258e7a9e6e diff --git a/package-lock.json b/package-lock.json index 3d3668970150..fef6b54dcc7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.3.75-1", + "version": "9.3.78-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.3.75-1", + "version": "9.3.78-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -50,7 +50,7 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", - "@shopify/react-native-skia": "^2.4.14", + "@shopify/react-native-skia": "^2.6.2", "@ua/react-native-airship": "~25.0.0", "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", @@ -115,7 +115,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.35.0", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.71", + "react-native-onyx": "3.0.73", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -14299,16 +14299,21 @@ } }, "node_modules/@shopify/react-native-skia": { - "version": "2.4.14", - "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz", - "integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.6.2.tgz", + "integrity": "sha512-NzZ3+MRedZAUhguWw9DTCpWFd09Bq+tdGWhimGfJLGckuyoWGyimTiNTmaO2DeeivHTnGdv+eXbw7j/AV3LkRQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "canvaskit-wasm": "0.40.0", + "canvaskit-wasm": "0.41.0", + "react-native-skia-android": "147.1.0", + "react-native-skia-apple-ios": "147.1.0", + "react-native-skia-apple-macos": "147.1.0", + "react-native-skia-apple-tvos": "147.1.0", "react-reconciler": "0.31.0" }, "bin": { + "install-skia": "scripts/install-libs.js", "setup-skia-web": "scripts/setup-canvaskit.js" }, "peerDependencies": { @@ -19631,9 +19636,9 @@ "license": "MIT" }, "node_modules/canvaskit-wasm": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", - "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.41.0.tgz", + "integrity": "sha512-cnbL02NFB3yOYMF/MtxViZHgD1vh55Pvy+zR8q4JuFvyCPejZP3eClkt2GuZ0S7jOmGMCJXaHBasbMChbR9JZg==", "license": "BSD-3-Clause", "dependencies": { "@webgpu/types": "0.1.21" @@ -34993,9 +34998,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.71", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.71.tgz", - "integrity": "sha512-q84y7aULjoRtQMjLP7fXYtI0nDROyfCgSKHEgZNHBwSvSogOEbGbsHw9qGm6jFIjIoOJf6Hzd3+FnIrFu+WVEQ==", + "version": "3.0.73", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.73.tgz", + "integrity": "sha512-0fvL8q7Rx3QBoHJJLB4e2wd0a9/4f//FvTBGKJliU5rkO/05APKZKFxMprSQpBm+O+i3T2R1lwSQbeaj9AbIDA==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -35261,6 +35266,30 @@ "node": ">=16" } }, + "node_modules/react-native-skia-android": { + "version": "147.1.0", + "resolved": "https://registry.npmjs.org/react-native-skia-android/-/react-native-skia-android-147.1.0.tgz", + "integrity": "sha512-pWA0M0G74AhjEop0HLCkjWJMup2HJxOmuUjfPt6kSDhYeWKVx8AEzWh0Fh19ah78zE/s4hD0Of0Tyem5shhiTg==", + "license": "MIT" + }, + "node_modules/react-native-skia-apple-ios": { + "version": "147.1.0", + "resolved": "https://registry.npmjs.org/react-native-skia-apple-ios/-/react-native-skia-apple-ios-147.1.0.tgz", + "integrity": "sha512-cr4rWe4Bf0H0TTutUp5cgHt5/Felttl1bh4BAAAsgAeL2F10FAK9urX8spjUshzMwjqXD7rNOWuFzU6ZcNlGKw==", + "license": "MIT" + }, + "node_modules/react-native-skia-apple-macos": { + "version": "147.1.0", + "resolved": "https://registry.npmjs.org/react-native-skia-apple-macos/-/react-native-skia-apple-macos-147.1.0.tgz", + "integrity": "sha512-Qbv0Y7LgawtRKuGk8gnGeh8nDWwNiu03LcX0mVaQzBBbxFDYvqejanA+AkO3p8gsQb+fsXRc9DAk+U8cBnzZvA==", + "license": "MIT" + }, + "node_modules/react-native-skia-apple-tvos": { + "version": "147.1.0", + "resolved": "https://registry.npmjs.org/react-native-skia-apple-tvos/-/react-native-skia-apple-tvos-147.1.0.tgz", + "integrity": "sha512-b+4vILXHPu++t8H41PHLBVsTab2LPqwXNdzgdScyl4+Cu8Ta34aUQW3T469cB0ogAMPdu//KV00w4YVpDZoRUQ==", + "license": "MIT" + }, "node_modules/react-native-svg": { "version": "15.12.1", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", diff --git a/package.json b/package.json index d74b2c17a9c4..c5f470813cfa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.3.75-1", + "version": "9.3.78-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -114,7 +114,7 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", - "@shopify/react-native-skia": "^2.4.14", + "@shopify/react-native-skia": "^2.6.2", "@ua/react-native-airship": "~25.0.0", "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", @@ -179,7 +179,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.35.0", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.71", + "react-native-onyx": "3.0.73", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From a9fc9c49760877e83f6f8c0922705618bf596177 Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Thu, 21 May 2026 08:13:10 +0000 Subject: [PATCH 5/6] Remove obsolete react-native-onyx patch The patch reverted Onyx PR #770 (skippable collection member IDs). That revert was merged upstream in react-native-onyx PR #785 and is included in the 3.0.73 release this branch already uses, so the patch-package file is no longer needed. Co-authored-by: Yauheni Horbach --- patches/react-native-onyx/details.md | 11 --- .../react-native-onyx+3.0.71.patch | 67 ------------------- 2 files changed, 78 deletions(-) delete mode 100644 patches/react-native-onyx/details.md delete mode 100644 patches/react-native-onyx/react-native-onyx+3.0.71.patch diff --git a/patches/react-native-onyx/details.md b/patches/react-native-onyx/details.md deleted file mode 100644 index 765902c6dd6c..000000000000 --- a/patches/react-native-onyx/details.md +++ /dev/null @@ -1,11 +0,0 @@ -# `react-native-onyx` patches - -### [react-native-onyx+3.0.71.patch](react-native-onyx+3.0.71.patch) - -- Reason: - - > Reverts [Onyx PR #770 (the subscription-side skip for skippable collection member ids in subscribeToKey)](https://github.com/Expensify/react-native-onyx/pull/770) and the line [PR #779](https://github.com/Expensify/react-native-onyx/pull/779) added to work around [PR #770](https://github.com/Expensify/react-native-onyx/pull/770)'s silent-no-callback contract. - -- Upstream PR/issue: https://github.com/Expensify/react-native-onyx/pull/785 -- E/App issue: https://github.com/Expensify/App/issues/86181 -- PR Introducing Patch: https://github.com/Expensify/App/pull/90764 \ No newline at end of file diff --git a/patches/react-native-onyx/react-native-onyx+3.0.71.patch b/patches/react-native-onyx/react-native-onyx+3.0.71.patch deleted file mode 100644 index 90666509eba5..000000000000 --- a/patches/react-native-onyx/react-native-onyx+3.0.71.patch +++ /dev/null @@ -1,67 +0,0 @@ -diff --git a/node_modules/react-native-onyx/dist/OnyxUtils.js b/node_modules/react-native-onyx/dist/OnyxUtils.js -index de56f94..495d378 100644 ---- a/node_modules/react-native-onyx/dist/OnyxUtils.js -+++ b/node_modules/react-native-onyx/dist/OnyxUtils.js -@@ -868,24 +868,6 @@ function subscribeToKey(connectOptions) { - const subscriptionID = lastSubscriptionID++; - callbackToStateMapping[subscriptionID] = mapping; - callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID; -- // If the subscriber is attempting to connect to a collection member whose ID is skippable (e.g. "undefined", "null", etc.) -- // we suppress wiring the subscription fully to avoid unnecessary callback emissions such as for "report_undefined". -- // We still return a valid subscriptionID so callers can disconnect safely. -- try { -- const skippableIDs = getSkippableCollectionMemberIDs(); -- if (skippableIDs.size) { -- const [, collectionMemberID] = OnyxKeys_1.default.splitCollectionMemberKey(mapping.key); -- if (skippableIDs.has(collectionMemberID)) { -- // Clean up the provisional mapping to avoid retaining unused subscribers. -- OnyxCache_1.default.addNullishStorageKey(mapping.key); -- delete callbackToStateMapping[subscriptionID]; -- return subscriptionID; -- } -- } -- } -- catch (e) { -- // Not a collection member key, proceed as usual. -- } - // When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID - // to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key), - // We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs. -@@ -1394,12 +1376,6 @@ function logKeyChanged(onyxMethod, key, value, hasChanged) { - function logKeyRemoved(onyxMethod, key) { - Logger.logInfo(`${onyxMethod} called for key: ${key} => null passed, so key was removed`); - } --/** -- * Getter - returns the callback to state mapping, useful in test environments. -- */ --function getCallbackToStateMapping() { -- return callbackToStateMapping; --} - /** - * Clear internal variables used in this file, useful in test environments. - */ -@@ -1458,6 +1434,5 @@ const OnyxUtils = { - setWithRetry, - multiSetWithRetry, - setCollectionWithRetry, -- getCallbackToStateMapping, - }; - exports.default = OnyxUtils; -diff --git a/node_modules/react-native-onyx/dist/useOnyx.js b/node_modules/react-native-onyx/dist/useOnyx.js -index 9213cff..2e48c73 100644 ---- a/node_modules/react-native-onyx/dist/useOnyx.js -+++ b/node_modules/react-native-onyx/dist/useOnyx.js -@@ -220,12 +220,8 @@ function useOnyx(key, options, dependencies = []) { - newValueRef.current = null; - sourceValueRef.current = undefined; - resultRef.current = [undefined, { status: (options === null || options === void 0 ? void 0 : options.initWithStoredValues) === false ? 'loaded' : 'loading' }]; -+ shouldGetCachedValueRef.current = true; - } -- // Force a cache re-read on every (re)subscription so any side effects from -- // subscribeToKey (e.g. addNullishStorageKey for skippable collection member ids) -- // are reflected in the next getSnapshot. Resetting this flag does not change -- // resultRef by itself, so it doesn't cause an extra mount render. -- shouldGetCachedValueRef.current = true; - hasMountedRef.current = true; - isConnectingRef.current = true; - onStoreChangeFnRef.current = onStoreChange; From ea951dbf65d35ec4d10f08968622077b3141d772 Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Thu, 21 May 2026 08:29:04 +0000 Subject: [PATCH 6/6] Revert HybridApp changes, keep only Skia update Remove the original HybridApp await-before-close changes per reviewer request. This PR now only updates @shopify/react-native-skia from 2.4.14 to 2.6.2. Co-authored-by: Yauheni Horbach --- src/libs/actions/HybridApp/index.ts | 39 +++-------------------------- tests/unit/HybridAppActionsTest.ts | 31 +---------------------- 2 files changed, 5 insertions(+), 65 deletions(-) diff --git a/src/libs/actions/HybridApp/index.ts b/src/libs/actions/HybridApp/index.ts index 84e5027a92ca..deeaf24a3bc7 100644 --- a/src/libs/actions/HybridApp/index.ts +++ b/src/libs/actions/HybridApp/index.ts @@ -1,7 +1,6 @@ import HybridAppModule from '@expensify/react-native-hybrid-app'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {isLockedToNewApp, shouldBlockOldAppExit} from '@libs/TryNewDotUtils'; import {setIsGPSInProgressModalOpen} from '@userActions/isGPSInProgressModalOpen'; @@ -15,7 +14,6 @@ let currentSessionAccountID: Session['accountID']; let isLoadingApp = true; let isLoadingTryNewDot = true; let hasReceivedTryNewDotUpdate = false; -let isClosingReactNativeApp = false; function getSessionAccountID(session: OnyxEntry): Session['accountID'] { return session?.accountID; @@ -35,17 +33,6 @@ function updateTryNewDotLoadingState(isTryNewDotUpdate = false, isInitialTryNewD isLoadingTryNewDot = isLoadingApp !== false; } -Onyx.connectWithoutView({ - key: ONYXKEYS.HYBRID_APP, - callback: (hybridApp) => { - if (hybridApp?.closingReactNativeApp) { - return; - } - - isClosingReactNativeApp = false; - }, -}); - Onyx.connectWithoutView({ key: ONYXKEYS.NVP_TRY_NEW_DOT, callback: (tryNewDot) => { @@ -81,7 +68,6 @@ Onyx.connectWithoutView({ currentTryNewDot = undefined; hasReceivedTryNewDotUpdate = false; - isClosingReactNativeApp = false; isLoadingTryNewDot = nextSessionAccountID !== undefined || isLoadingApp !== false; }, }); @@ -123,30 +109,13 @@ function closeReactNativeApp({shouldSetNVP, isTrackingGPS, shouldIgnoreTryNewDot return; } - if (CONFIG.IS_HYBRID_APP && isClosingReactNativeApp) { - return; - } - + Navigation.clearPreloadedRoutes(); if (CONFIG.IS_HYBRID_APP) { - isClosingReactNativeApp = true; + Onyx.merge(ONYXKEYS.HYBRID_APP, {closingReactNativeApp: true}); } - const closingPromise = CONFIG.IS_HYBRID_APP ? Onyx.merge(ONYXKEYS.HYBRID_APP, {closingReactNativeApp: true}) : Promise.resolve(); - - closingPromise - .then(() => { - Navigation.clearPreloadedRoutes(); - // We need to call HybridAppModule.closeReactNativeApp directly as a native module method - // eslint-disable-next-line no-restricted-properties - HybridAppModule.closeReactNativeApp({shouldSetNVP}); - }) - .catch((error) => { - Log.warn('Failed to merge HYBRID_APP closing state', {error}); - // Still attempt to close the app to avoid leaving the user stuck - Navigation.clearPreloadedRoutes(); - // eslint-disable-next-line no-restricted-properties - HybridAppModule.closeReactNativeApp({shouldSetNVP}); - }); + // eslint-disable-next-line no-restricted-properties + HybridAppModule.closeReactNativeApp({shouldSetNVP}); } /* diff --git a/tests/unit/HybridAppActionsTest.ts b/tests/unit/HybridAppActionsTest.ts index c1bffd8a1b51..2a960283a840 100644 --- a/tests/unit/HybridAppActionsTest.ts +++ b/tests/unit/HybridAppActionsTest.ts @@ -87,7 +87,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); @@ -98,7 +97,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false, shouldIgnoreTryNewDotLoading: true}); - await waitForBatchedUpdatesWithAct(); expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); @@ -140,7 +138,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); jest.clearAllMocks(); @@ -149,7 +146,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).not.toHaveBeenCalled(); await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); @@ -158,7 +154,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); @@ -178,7 +173,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); jest.clearAllMocks(); @@ -190,7 +184,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).not.toHaveBeenCalled(); await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, { @@ -201,7 +194,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); @@ -227,7 +219,6 @@ describe('HybridApp actions', () => { // closeReactNativeApp should still work because the initial session load // must not blank the already-populated currentTryNewDot closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); @@ -247,7 +238,6 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); jest.clearAllMocks(); @@ -258,32 +248,13 @@ describe('HybridApp actions', () => { await waitForBatchedUpdatesWithAct(); closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); }); - it('preserves shouldSetNVP false exits for existing non-force-mobile flows', async () => { + it('preserves shouldSetNVP false exits for existing non-force-mobile flows', () => { closeReactNativeApp({shouldSetNVP: false, isTrackingGPS: false}); - await waitForBatchedUpdatesWithAct(); expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: false}); }); - - it('awaits Onyx merge before calling native close and clearing routes', async () => { - await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); - await waitForBatchedUpdatesWithAct(); - - closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false}); - - // Before the promise resolves, neither route clearing nor native close should have been called - expect(Navigation.clearPreloadedRoutes).not.toHaveBeenCalled(); - expect(closeNativeAppSpy).not.toHaveBeenCalled(); - - await waitForBatchedUpdatesWithAct(); - - // After the promise resolves, both should have been called - expect(Navigation.clearPreloadedRoutes).toHaveBeenCalled(); - expect(closeNativeAppSpy).toHaveBeenCalledWith({shouldSetNVP: true}); - }); });