From f47edd7ef32c9ec5daf061bd522d21dd43173092 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 28 Sep 2026 14:33:53 +0200 Subject: [PATCH 01/15] chore(deps): upgrade NitroSQLite to 10.0.0 --- ios/NewExpensify/Info.plist | 2 ++ ios/Podfile.lock | 4 ++-- package-lock.json | 18 +++++++++--------- package.json | 4 ++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 72050233c254..f7cce74f6de1 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -95,6 +95,8 @@ RCTNewArchEnabled + + RNNitroSQLite_DatabaseLocation ApplicationSupport UIAppFonts diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 12b2ec73beb0..e21d3b5aa997 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3028,7 +3028,7 @@ PODS: - ReactNativeDependencies - Turf - Yoga - - RNNitroSQLite (9.8.2): + - RNNitroSQLite (10.0.0): - boost - DoubleConversion - fast_float @@ -4111,7 +4111,7 @@ SPEC CHECKSUMS: RNLiveMarkdown: f4c383d846d5d756f42bdf6006e85d5baf0ebfc8 RNLocalize: 82e7aa2857ce5dcd9e2a0c04f8c34c2ed50821df rnmapbox-maps: 54eb40428a4fd89f484a492fc7186bc1c1eea9ce - RNNitroSQLite: c986393b7cb0e5e4587d0c9f7108e56ee8db9627 + RNNitroSQLite: c37abcb76403d6538a1eb83dc21e4b4a9ea444b8 RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73 RNReanimated: f093c30aa11e45a8044dcc19417f44cffa1c4e1d diff --git a/package-lock.json b/package-lock.json index f3bf0ffbc167..5faeeeab6062 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,8 +125,8 @@ "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", - "react-native-nitro-sqlite": "9.8.2", - "react-native-onyx": "3.0.113", + "react-native-nitro-sqlite": "10.0.0", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#1fa80540e7b428adf42a5f7614015888d5a38a17", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -37693,9 +37693,9 @@ } }, "node_modules/react-native-nitro-sqlite": { - "version": "9.8.2", - "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-9.8.2.tgz", - "integrity": "sha512-h2qBbC/KwD6f6+YDHm3hLRCEPB129FRilrKRlI47LD4O6UnS85+gityEJ0rcpe4vLGOzeRhwqRpKpxfIh5ak/Q==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.0.0.tgz", + "integrity": "sha512-3+tcmKWjS2covvqBsK3EvpiClSekllwVOTRO3BzwsapIf/hvawcbippcPi1gcwEORS41UcQr0oI/QgYoJrjK2Q==", "license": "MIT", "dependencies": { "typeorm": "0.3.27" @@ -37872,9 +37872,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.113", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.113.tgz", - "integrity": "sha512-6+pk3ArtP2QcsH+IlXLgNWeWiC7c9abwoTVs9yxzWuMUHlHkyQ7QvsxMM5f2iYlX3qBcFkHqU2puGsnhhKy7mw==", + "version": "3.0.116", + "resolved": "git+https://github.com/margelo/react-native-onyx.git#1fa80540e7b428adf42a5f7614015888d5a38a17", + "integrity": "sha512-jTKM471Z65yfTMqXJs/xDpIPVrvhNqadASMdbGo5rfNdheFxLs7Wtn8a/y0cp2+KBX02k5AP1+oZSnRJyOzyCQ==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -37895,7 +37895,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^9.6.0" + "react-native-nitro-sqlite": "^10.0.0" }, "peerDependenciesMeta": { "idb-keyval": { diff --git a/package.json b/package.json index 4416d04d1925..b7a66f0be355 100644 --- a/package.json +++ b/package.json @@ -201,8 +201,8 @@ "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", - "react-native-nitro-sqlite": "9.8.2", - "react-native-onyx": "3.0.113", + "react-native-nitro-sqlite": "10.0.0", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#1fa80540e7b428adf42a5f7614015888d5a38a17", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From a0d509d326e6ec425853bd2b10fd5bacac91d5c5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 28 Sep 2026 15:14:00 +0200 Subject: [PATCH 02/15] chore(deps): pin Onyx NitroSQLite 10 integration --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5faeeeab6062..feff9f17962d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,7 +126,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.0.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#1fa80540e7b428adf42a5f7614015888d5a38a17", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#455b30aee6ea0ad66de34307686b59b409221d83", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -37873,8 +37873,8 @@ }, "node_modules/react-native-onyx": { "version": "3.0.116", - "resolved": "git+https://github.com/margelo/react-native-onyx.git#1fa80540e7b428adf42a5f7614015888d5a38a17", - "integrity": "sha512-jTKM471Z65yfTMqXJs/xDpIPVrvhNqadASMdbGo5rfNdheFxLs7Wtn8a/y0cp2+KBX02k5AP1+oZSnRJyOzyCQ==", + "resolved": "git+https://github.com/margelo/react-native-onyx.git#455b30aee6ea0ad66de34307686b59b409221d83", + "integrity": "sha512-MoJjknS7iPcF2QucwR3iL5Xc3GHnPd3Wlzc8gezqoKQi5H1c3x4U686gROxgPC7EhcyAL9OAhXOXMvS9Yhcm8w==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index b7a66f0be355..d747769ee1f0 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.0.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#1fa80540e7b428adf42a5f7614015888d5a38a17", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#455b30aee6ea0ad66de34307686b59b409221d83", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From 3d6d5d26227a31f06f171f80c6794e35e288c173 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 28 Sep 2026 15:19:51 +0200 Subject: [PATCH 03/15] chore(deps): pin formatted Onyx NitroSQLite integration --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index feff9f17962d..77ed2a4b6695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,7 +126,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.0.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#455b30aee6ea0ad66de34307686b59b409221d83", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6e00ff0ecd410a09e80d90dedb548db66c4601ef", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -37873,8 +37873,8 @@ }, "node_modules/react-native-onyx": { "version": "3.0.116", - "resolved": "git+https://github.com/margelo/react-native-onyx.git#455b30aee6ea0ad66de34307686b59b409221d83", - "integrity": "sha512-MoJjknS7iPcF2QucwR3iL5Xc3GHnPd3Wlzc8gezqoKQi5H1c3x4U686gROxgPC7EhcyAL9OAhXOXMvS9Yhcm8w==", + "resolved": "git+https://github.com/margelo/react-native-onyx.git#6e00ff0ecd410a09e80d90dedb548db66c4601ef", + "integrity": "sha512-3b4ucn2SHgdX5Pm/xgksK6xJCuGJ9KIOXpQq7pGIMXbbTFNzxuChYavKq6HTeFh6uf3Sm31NINO0X9bzy82B5g==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index d747769ee1f0..ff7d946aa6a1 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.0.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#455b30aee6ea0ad66de34307686b59b409221d83", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6e00ff0ecd410a09e80d90dedb548db66c4601ef", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From 725d43653b6832a339ca23d4d600819b9060de48 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 1 Oct 2026 10:45:20 +0200 Subject: [PATCH 04/15] chore: remove redundant NitroSQLite plist comments --- ios/NewExpensify/Info.plist | 2 -- 1 file changed, 2 deletions(-) diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index f7cce74f6de1..72050233c254 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -95,8 +95,6 @@ RCTNewArchEnabled - - RNNitroSQLite_DatabaseLocation ApplicationSupport UIAppFonts From 4197de48c4133b0a98fd8a764f319a47831a27cb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 17:16:03 +0100 Subject: [PATCH 05/15] chore(deps): update Onyx for NitroSQLite 10.2.0 --- ios/Podfile.lock | 9 +- package-lock.json | 286 +++++++++-------------- package.json | 4 +- src/libs/Network/SequentialQueue.ts | 3 - src/setup/addUtilsToWindow.ts | 15 -- src/types/modules/react-native-onyx.d.ts | 1 - 6 files changed, 117 insertions(+), 201 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e21d3b5aa997..f50354ecd35e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3028,12 +3028,7 @@ PODS: - ReactNativeDependencies - Turf - Yoga - - RNNitroSQLite (10.0.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog + - RNNitroSQLite (10.2.0): - hermes-engine - NitroModules - RCTRequired @@ -4111,7 +4106,7 @@ SPEC CHECKSUMS: RNLiveMarkdown: f4c383d846d5d756f42bdf6006e85d5baf0ebfc8 RNLocalize: 82e7aa2857ce5dcd9e2a0c04f8c34c2ed50821df rnmapbox-maps: 54eb40428a4fd89f484a492fc7186bc1c1eea9ce - RNNitroSQLite: c37abcb76403d6538a1eb83dc21e4b4a9ea444b8 + RNNitroSQLite: 8962a30dc9296f8a62bfcc33f3c2a540b1c44aff RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73 RNReanimated: f093c30aa11e45a8044dcc19417f44cffa1c4e1d diff --git a/package-lock.json b/package-lock.json index d85126618098..8d41913a5d89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,8 +126,8 @@ "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", - "react-native-nitro-sqlite": "10.0.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6e00ff0ecd410a09e80d90dedb548db66c4601ef", + "react-native-nitro-sqlite": "10.2.0", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#f94b943a580fc9d7f39ab89c562c6145ed401df3", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -10655,6 +10655,7 @@ }, "node_modules/@isaacs/cliui": { "version": "8.0.2", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -10670,6 +10671,7 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { "version": "6.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10680,6 +10682,7 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { "version": "6.2.1", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10690,10 +10693,12 @@ }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", + "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -10709,6 +10714,7 @@ }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.0", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -10722,6 +10728,7 @@ }, "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { "version": "8.1.0", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -14340,6 +14347,7 @@ }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -21710,9 +21718,9 @@ } }, "node_modules/ansis": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", - "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", + "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", "license": "ISC", "engines": { "node": ">=14" @@ -21730,15 +21738,6 @@ "node": ">= 8" } }, - "node_modules/app-root-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", - "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/appdirsjs": { "version": "1.2.7", "devOptional": true, @@ -22650,6 +22649,7 @@ }, "node_modules/balanced-match": { "version": "1.0.2", + "devOptional": true, "license": "MIT" }, "node_modules/base-64": { @@ -25029,7 +25029,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -25425,6 +25427,7 @@ }, "node_modules/eastasianwidth": { "version": "0.2.0", + "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -28428,6 +28431,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -28442,6 +28446,7 @@ }, "node_modules/foreground-child/node_modules/signal-exit": { "version": "4.1.0", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -28905,6 +28910,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -28951,6 +28957,7 @@ }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -28958,6 +28965,7 @@ }, "node_modules/glob/node_modules/minimatch": { "version": "9.0.5", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -30579,6 +30587,7 @@ }, "node_modules/jackspeak": { "version": "3.4.3", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -36028,6 +36037,7 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.0", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { @@ -36303,6 +36313,7 @@ }, "node_modules/path-scurry": { "version": "1.11.1", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -36317,6 +36328,7 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.2.0", + "dev": true, "license": "ISC", "engines": { "node": "14 || >=16.14" @@ -37694,41 +37706,23 @@ } }, "node_modules/react-native-nitro-sqlite": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.0.0.tgz", - "integrity": "sha512-3+tcmKWjS2covvqBsK3EvpiClSekllwVOTRO3BzwsapIf/hvawcbippcPi1gcwEORS41UcQr0oI/QgYoJrjK2Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.2.0.tgz", + "integrity": "sha512-lB0yc4SzVNTZoe0ItyBJqOQ9qAc2SY3ku7PzFwP97e4NpZIOX0ec7p49sLK04MdWRmInt96Q7NU4rPfbNyg7Nw==", "license": "MIT", "dependencies": { - "typeorm": "0.3.27" + "typeorm": "1.1.1" }, "peerDependencies": { + "better-sqlite3": "^12.11.1", "react": ">=17.0.0", "react-native": ">=0.75.0", "react-native-nitro-modules": ">=0.35.0" - } - }, - "node_modules/react-native-nitro-sqlite/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" } }, "node_modules/react-native-nitro-sqlite/node_modules/dedent": { @@ -37745,38 +37739,67 @@ } } }, - "node_modules/react-native-nitro-sqlite/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", + "node_modules/react-native-nitro-sqlite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/react-native-nitro-sqlite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", "engines": { "node": ">=12" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/react-native-nitro-sqlite/node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/react-native-nitro-sqlite/node_modules/typeorm": { - "version": "0.3.27", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", - "integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.1.1.tgz", + "integrity": "sha512-og9mG4Lwlvj6MfvYd8yRCzXy73gETtwgTKQ/ISLYvEOpJPy4IrjU/89HK4ZAFmRIbEg1hkIk04v8phTrxfgPmQ==", "license": "MIT", "dependencies": { "@sqltools/formatter": "^1.2.5", - "ansis": "^3.17.0", - "app-root-path": "^3.1.0", - "buffer": "^6.0.3", - "dayjs": "^1.11.13", - "debug": "^4.4.0", - "dedent": "^1.6.0", - "dotenv": "^16.4.7", - "glob": "^10.4.5", - "sha.js": "^2.4.12", - "sql-highlight": "^6.0.0", + "ansis": "^4.3.1", + "dayjs": "^1.11.21", + "debug": "^4.4.3", + "dedent": "^1.7.2", + "reflect-metadata": "^0.2.2", + "sql-highlight": "^6.1.0", + "tinyglobby": "^0.2.17", "tslib": "^2.8.1", - "uuid": "^11.1.0", - "yargs": "^17.7.2" + "yargs": "^18.0.0" }, "bin": { "typeorm": "cli.js", @@ -37784,29 +37807,27 @@ "typeorm-ts-node-esm": "cli-ts-node-esm.js" }, "engines": { - "node": ">=16.13.0" + "node": "^20.19.0 || ^22.13.0 || >=24.11.0" }, "funding": { "url": "https://opencollective.com/typeorm" }, "peerDependencies": { - "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0", + "@google-cloud/spanner": "^8.0.0", "@sap/hana-client": "^2.14.22", - "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "better-sqlite3": "^12.0.0", "ioredis": "^5.0.4", - "mongodb": "^5.8.0 || ^6.0.0", - "mssql": "^9.1.1 || ^10.0.1 || ^11.0.1", - "mysql2": "^2.2.5 || ^3.0.1", - "oracledb": "^6.3.0", + "mongodb": "^7.0.0", + "mssql": "^12.0.0", + "mysql2": "^3.15.3", + "oracledb": "^6.3.0 || ^7.0.0", "pg": "^8.5.1", "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", - "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", - "reflect-metadata": "^0.1.14 || ^0.2.0", + "redis": "^5.0.0 || ^6.0.0", "sql.js": "^1.4.0", - "sqlite3": "^5.0.3", - "ts-node": "^10.7.0", - "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + "ts-node": "^10.9.2", + "typeorm-aurora-data-api-driver": "^3.0.0" }, "peerDependenciesMeta": { "@google-cloud/spanner": { @@ -37848,9 +37869,6 @@ "sql.js": { "optional": true }, - "sqlite3": { - "optional": true - }, "ts-node": { "optional": true }, @@ -37859,23 +37877,10 @@ } } }, - "node_modules/react-native-nitro-sqlite/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/react-native-onyx": { - "version": "3.0.116", - "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#6e00ff0ecd410a09e80d90dedb548db66c4601ef", - "integrity": "sha512-jTKM471Z65yfTMqXJs/xDpIPVrvhNqadASMdbGo5rfNdheFxLs7Wtn8a/y0cp2+KBX02k5AP1+oZSnRJyOzyCQ==", + "version": "3.0.119", + "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#f94b943a580fc9d7f39ab89c562c6145ed401df3", + "integrity": "sha512-uTIZPMq4ogBsvMlo55Fc82G1BcSe1txopT+yoCwUwGgJsMK7JCr2J0ezWuU2eJx0gQvogM/nU7ixyL/sPGRloA==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -37884,7 +37889,8 @@ "lodash.clone": "^4.5.0", "lodash.pick": "^4.4.0", "lodash.transform": "^4.6.0", - "underscore": "^1.13.6" + "underscore": "^1.13.6", + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=20.19.5", @@ -37896,7 +37902,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^10.0.0" + "react-native-nitro-sqlite": "^10.2.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -38717,8 +38723,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -39630,46 +39635,6 @@ "node": ">=10" } }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sha.js/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -41175,6 +41140,7 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -41334,6 +41300,7 @@ "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -41344,6 +41311,7 @@ }, "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -41972,40 +41940,6 @@ "version": "1.0.5", "license": "BSD-3-Clause" }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/to-buffer/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", @@ -42685,7 +42619,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -43597,6 +43533,7 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -43612,6 +43549,7 @@ }, "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { "version": "4.3.0", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -43625,6 +43563,7 @@ }, "node_modules/wrap-ansi-cjs/node_modules/color-convert": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -43635,6 +43574,7 @@ }, "node_modules/wrap-ansi-cjs/node_modules/color-name": { "version": "1.1.4", + "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/ansi-styles": { diff --git a/package.json b/package.json index f1c52bc86ed0..3e5a5c1f428f 100644 --- a/package.json +++ b/package.json @@ -202,8 +202,8 @@ "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", - "react-native-nitro-sqlite": "10.0.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6e00ff0ecd410a09e80d90dedb548db66c4601ef", + "react-native-nitro-sqlite": "10.2.0", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#f94b943a580fc9d7f39ab89c562c6145ed401df3", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 9ef4ba181e7a..24fe33b21c1a 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -689,9 +689,6 @@ function flush(shouldResetPromise = true) { // Use connectWithoutView since this is for network queue and don't affect to any UI const connection = Onyx.connectWithoutView({ key: ONYXKEYS.PERSISTED_REQUESTS, - // We exceptionally opt out of reusing the connection here to avoid extra callback calls due to - // an existing connection already made in PersistedRequests.ts. - reuseConnection: false, callback: () => { Log.info('[SequentialQueue] PERSISTED_REQUESTS loaded, starting process()', false, { requestsLength: getAllPersistedRequests().length, diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts index ec3935f4e2b3..e1147d5cd66b 100644 --- a/src/setup/addUtilsToWindow.ts +++ b/src/setup/addUtilsToWindow.ts @@ -24,24 +24,9 @@ export default function addUtilsToWindow() { } window.Onyx = Onyx as typeof Onyx & { - get: (key: CollectionKeyBase) => Promise; log: (key: CollectionKeyBase) => void; }; - // We intentionally do not offer an Onyx.get API because we believe it will lead to code patterns we don't want to use in this repo, but we can offer a workaround for the sake of debugging - window.Onyx.get = function (key: CollectionKeyBase) { - return new Promise((resolve) => { - // We have opted for `connectWithoutView` here as this is a debugging utility and does not relate to any view. - const connection = Onyx.connectWithoutView({ - key, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - }; - window.Onyx.log = function (key: CollectionKeyBase) { window.Onyx.get(key).then((value) => { /* eslint-disable-next-line no-console */ diff --git a/src/types/modules/react-native-onyx.d.ts b/src/types/modules/react-native-onyx.d.ts index 73615460afd9..c091f53bce96 100644 --- a/src/types/modules/react-native-onyx.d.ts +++ b/src/types/modules/react-native-onyx.d.ts @@ -16,7 +16,6 @@ declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { Onyx: typeof Onyx & { - get: (key: CollectionKeyBase) => Promise; log: (key: CollectionKeyBase) => void; }; } From 682344e0d197f4bebaf37a8392360f76b601b23f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 17:37:58 +0100 Subject: [PATCH 06/15] chore(deps): pin Onyx error constant update --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8d41913a5d89..2a4dd64679f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -127,7 +127,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#f94b943a580fc9d7f39ab89c562c6145ed401df3", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#433c23e47baa2df4bca2c9e1070ba40de902642c", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -37879,8 +37879,8 @@ }, "node_modules/react-native-onyx": { "version": "3.0.119", - "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#f94b943a580fc9d7f39ab89c562c6145ed401df3", - "integrity": "sha512-uTIZPMq4ogBsvMlo55Fc82G1BcSe1txopT+yoCwUwGgJsMK7JCr2J0ezWuU2eJx0gQvogM/nU7ixyL/sPGRloA==", + "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#433c23e47baa2df4bca2c9e1070ba40de902642c", + "integrity": "sha512-JQIilqj097+2y2diBesMIyITSjKxYuT7KSdVY2cLPYKTC2RT8t7G7W/bKd1dnQbaenmhpnY/zH4Dhx3eYj4geg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index 3e5a5c1f428f..6adf8a0663f6 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#f94b943a580fc9d7f39ab89c562c6145ed401df3", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#433c23e47baa2df4bca2c9e1070ba40de902642c", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From 1d16f4cef6b249d85383667d9f4c115e9ebae885 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 18:39:17 +0100 Subject: [PATCH 07/15] test(onyx): fix fixtures for updated subscription behavior --- tests/actions/IOU/MoneyRequestSettersTest.ts | 7 +- tests/actions/IOU/RequestMoneyTest.ts | 122 +++++++----------- tests/actions/MergeTransactionTest.ts | 2 +- tests/actions/ReportTest.ts | 5 +- tests/actions/SessionTest.ts | 50 ++++--- tests/actions/TransactionEditTest.ts | 10 +- tests/unit/CategoryTagPickerAutoFocusTest.tsx | 4 + tests/unit/ReportUtilsGetIconsTest.ts | 3 - tests/unit/TelemetrySynchronizerTest.ts | 1 + .../components/Charts/SkiaWebChart.test.tsx | 4 +- tests/unit/hooks/useInsightsFilters.test.ts | 16 ++- ...MoneyRequestParticipantsPolicyTags.test.ts | 2 +- tests/unit/onyxDerivedComputeSpanTest.ts | 1 + 13 files changed, 114 insertions(+), 113 deletions(-) diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index 7ea9cf1fdb04..ef4c5ee03f58 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -693,12 +693,13 @@ describe('actions/IOU', () => { describe('resetDraftTransactionsCustomUnit', () => { it('should do nothing if transaction is not passed', async () => { - // Call the reset function without a transaction + // Given an empty draft collection + // When the reset function is called without a transaction resetDraftTransactionsCustomUnit(undefined); await waitForBatchedUpdates(); const allDraftTransactions = await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT); - // Assuming there are no draft transactions, this should be undefined or an empty object - expect(allDraftTransactions).toBeUndefined(); + // Then no draft transaction is created + expect(allDraftTransactions).toEqual({}); }); it('should reset custom unit for a transaction', async () => { const transactionID = 'transaction_reset_001'; diff --git a/tests/actions/IOU/RequestMoneyTest.ts b/tests/actions/IOU/RequestMoneyTest.ts index 13b26da96cbc..cde2096c9adb 100644 --- a/tests/actions/IOU/RequestMoneyTest.ts +++ b/tests/actions/IOU/RequestMoneyTest.ts @@ -1,3 +1,5 @@ +import {waitFor} from '@testing-library/react-native'; + import type {SearchQueryJSON} from '@components/Search/types'; import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; @@ -401,28 +403,21 @@ describe('actions/IOU', () => { Onyx.disconnect(connection); // Snapshot data shouldn't be updated optimistically for requestMoney when the current search query type is invoice. - expect(snapshotData).toBeUndefined(); + expect(snapshotData).toEqual({}); resolve(); }, }); }), ) .then(mockFetch?.resume) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), + .then(() => + waitFor(async () => { + const reportActionsForIOUReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + }), ) .then( () => @@ -603,21 +598,14 @@ describe('actions/IOU', () => { ) .then(mockFetch?.resume) .then(waitForBatchedUpdates) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), + .then(() => + waitFor(async () => { + const reportActionsForIOUReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + }), ) .then( () => @@ -825,21 +813,14 @@ describe('actions/IOU', () => { ) .then(mockFetch?.resume) .then(waitForNetworkPromises) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), + .then(() => + waitFor(async () => { + const reportActionsForIOUReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + }), ) .then( () => @@ -1047,20 +1028,13 @@ describe('actions/IOU', () => { }); }), ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(transaction?.errors).toBeTruthy(); - expect(Object.values(transaction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.genericCreateFailureMessage')); - resolve(); - }, - }); - }), + .then(() => + waitFor(async () => { + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(transaction?.errors).toBeTruthy(); + expect(Object.values(transaction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.genericCreateFailureMessage')); + }), ) // If the user clears the errors on the IOU action @@ -1380,14 +1354,14 @@ describe('actions/IOU', () => { mockFetch?.fail?.(); await (mockFetch?.resume?.() as Promise); - // Verify error handling after failure - focus on workspace invitation error - const policyData = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); - - // The new accountant should have been added to the employee list with error - const accountantEmployee = policyData?.employeeList?.[accountant.email]; - expect(accountantEmployee).toBeTruthy(); - expect(accountantEmployee?.errors).toBeTruthy(); - expect(Object.values(accountantEmployee?.errors ?? {}).at(0)).toEqual(translateLocal('workspace.people.error.genericAdd')); + // Then the failed request adds the workspace invitation error to the new accountant + await waitFor(async () => { + const policyData = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + const accountantEmployee = policyData?.employeeList?.[accountant.email]; + expect(accountantEmployee).toBeTruthy(); + expect(accountantEmployee?.errors).toBeTruthy(); + expect(Object.values(accountantEmployee?.errors ?? {}).at(0)).toEqual(translateLocal('workspace.people.error.genericAdd')); + }); // Cleanup mockFetch?.succeed?.(); @@ -3287,8 +3261,10 @@ describe('actions/IOU', () => { createTransaction(buildCreateTransactionParams(CONST.IOU.TYPE.TRACK, selfDMReport)); await waitForBatchedUpdates(); - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 1); - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 0); + await waitFor(() => { + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 1); + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 0); + }); }); it('routes the submit flow through requestMoney', async () => { @@ -3302,8 +3278,10 @@ describe('actions/IOU', () => { createTransaction(buildCreateTransactionParams(CONST.IOU.TYPE.SUBMIT, chatReport)); await waitForBatchedUpdates(); - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 1); - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 0); + await waitFor(() => { + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 1); + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 0); + }); }); }); }); diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index ad7ae04714a8..4b4c36705a99 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -1821,7 +1821,7 @@ describe('getTransactionsForMerging', () => { // Then no merge transaction entry is written for the empty key const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); - expect(mergeTransaction).toBeUndefined(); + expect(mergeTransaction).toEqual({}); }); it('builds the eligible list from reportTransactions when a workspace approver merges from a report they did not submit', async () => { diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index fae26ff72a54..82dc47a962e1 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -6355,15 +6355,18 @@ describe('actions/Report', () => { }); it('should not set anything if no reportID was provided', async () => { + // Given no report to attach the transaction thread to const reportID = undefined; + // When the thread creation is requested Report.setOptimisticTransactionThread(reportID); await waitForBatchedUpdates(); const reportsCollectionAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}`); - expect(reportsCollectionAfter).toBeUndefined(); + // Then the reports collection stays empty + expect(reportsCollectionAfter).toEqual({}); }); }); diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index 104afa04df90..b680ead7acbb 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -1,13 +1,14 @@ /* eslint-disable rulesdir/no-multiple-api-calls */ // cspell:ignore SOMESECRETKEY import {beforeEach, jest, test} from '@jest/globals'; +import {waitFor} from '@testing-library/react-native'; import {openApp, reconnectApp} from '@libs/actions/App'; import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {getAll as getAllPersistedRequests} from '@libs/actions/PersistedRequests'; import {initReconnect} from '@libs/actions/Reconnect'; import * as SignInRedirect from '@libs/actions/SignInRedirect'; -import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {AUTHENTICATION_COMMAND, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import asyncOpenURL from '@libs/asyncOpenURL'; import buildOldDotURL from '@libs/buildOldDotURL'; import getPlatform from '@libs/getPlatform'; @@ -304,37 +305,34 @@ describe('Session', () => { expect(session?.accountID).toBe(TEST_USER_ACCOUNT_ID); expect(session?.email).toBe(TEST_USER_LOGIN); - // At this point we have an authToken. To simulate it expiring we'll just make another - // request and mock the response so it returns 407. Once this happens we should attempt - // to Re-Authenticate with the stored credentials. Our next call will be to Authenticate - // so we will mock that response with a new authToken and then verify that Onyx has our - // data. - jest.mocked(HttpUtils.xhr) - - // This will make the call to OpenApp below return with an expired session code - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, - }), - ) - - // The next call should be Authenticate since we are re-authenticating - .mockImplementationOnce(() => - Promise.resolve({ + // Expire the next OpenApp response, then refresh the token through Authenticate. + // Match by command so background requests cannot consume those responses. + let hasExpiredSession = false; + jest.mocked(HttpUtils.xhr).mockImplementation((command) => { + if (command === WRITE_COMMANDS.OPEN_APP && !hasExpiredSession) { + hasExpiredSession = true; + return Promise.resolve({jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED}); + } + if (command === AUTHENTICATION_COMMAND) { + return Promise.resolve({ jsonCode: CONST.JSON_CODE.SUCCESS, accountID: TEST_USER_ACCOUNT_ID, authToken: TEST_REFRESHED_AUTH_TOKEN, email: TEST_USER_LOGIN, - }), - ); + }); + } + return Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS}); + }); - // When we attempt to fetch the initial app data via the API - openApp(); - await waitForBatchedUpdates(); + try { + // When we attempt to fetch the initial app data via the API + openApp(); - // Then it should fail and reauthenticate the user adding the new authToken to the session - // data in Onyx - expect(session?.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN); + // Then reauthentication updates the session with the refreshed token + await waitFor(() => expect(session?.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN)); + } finally { + jest.mocked(HttpUtils.xhr).mockReset(); + } }); test('Push notifications are subscribed after signing in', async () => { diff --git a/tests/actions/TransactionEditTest.ts b/tests/actions/TransactionEditTest.ts index 3a4eedfb3d54..5d2155768f65 100644 --- a/tests/actions/TransactionEditTest.ts +++ b/tests/actions/TransactionEditTest.ts @@ -71,12 +71,15 @@ describe('actions/TransactionEdit', () => { }); it('should handle null transaction gracefully', async () => { + // Given no transaction to back up + // When a backup is requested createBackupTransaction(undefined, false); await waitForBatchedUpdates(); const backups = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}`); - expect(backups).toBeUndefined(); + // Then no backup transaction is created + expect(backups).toEqual({}); }); }); @@ -131,12 +134,15 @@ describe('actions/TransactionEdit', () => { }); it('should handle null transactionID gracefully', async () => { + // Given no transaction ID to restore + // When a restore is requested restoreOriginalTransactionFromBackup(undefined, false); await waitForBatchedUpdates(); const transactions = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}`); - expect(transactions).toBeUndefined(); + // Then no transaction is created + expect(transactions).toEqual({}); }); }); diff --git a/tests/unit/CategoryTagPickerAutoFocusTest.tsx b/tests/unit/CategoryTagPickerAutoFocusTest.tsx index 9f9e7631abe5..6bc1bfa7fbd3 100644 --- a/tests/unit/CategoryTagPickerAutoFocusTest.tsx +++ b/tests/unit/CategoryTagPickerAutoFocusTest.tsx @@ -35,6 +35,10 @@ jest.mock('@components/SelectionList/SelectionListWithSections', () => ({ jest.mock('@libs/canFocusInputOnScreenFocus'); const mockedCanFocusInputOnScreenFocus = jest.mocked(canFocusInputOnScreenFocus); +// Keep the autofocus test independent of the pickers' background policy fetches. +jest.mock('@hooks/useLoadPolicyCategories', () => jest.fn(() => ({isLoadingPolicyCategories: false}))); +jest.mock('@hooks/useLoadPolicyTags', () => jest.fn(() => ({isLoadingPolicyTags: false, hasLoadedPolicyTags: true}))); + // `useAutoFocusInput` (still wired into both pickers) pulls in navigation. Stub it like the closest existing // precedent, tests/unit/BaseSelectionListSectionsTest.tsx, so the effect never runs in jsdom. jest.mock('@react-navigation/native', () => { diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts index 5edd49674bfb..9f20280483bf 100644 --- a/tests/unit/ReportUtilsGetIconsTest.ts +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -124,9 +124,6 @@ beforeAll(() => { ...FAKE_POLICIES, }, }); - // @ts-expect-error Until we add NVP_PRIVATE_DOMAINS to ONYXKEYS, we need to mock it here - - Onyx.connect({key: ONYXKEYS.NVP_PRIVATE_DOMAINS, callback: () => {}}); }); describe('getIcons', () => { diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts index a27ec1240645..e7261b6cfffd 100644 --- a/tests/unit/TelemetrySynchronizerTest.ts +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -16,6 +16,7 @@ import createRandomTransaction from '../utils/collections/transaction'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@sentry/react-native', () => ({ + setUser: jest.fn(), setTag: jest.fn(), setContext: jest.fn(), })); diff --git a/tests/unit/components/Charts/SkiaWebChart.test.tsx b/tests/unit/components/Charts/SkiaWebChart.test.tsx index 6cfc76811445..ebe567cc6b17 100644 --- a/tests/unit/components/Charts/SkiaWebChart.test.tsx +++ b/tests/unit/components/Charts/SkiaWebChart.test.tsx @@ -11,9 +11,9 @@ jest.mock('@components/Charts/SkiaWebChart/isSkiaWebSupported', () => jest.fn()) jest.mock('@shopify/react-native-skia/lib/module/web', () => ({WithSkiaWeb: jest.fn(() => null)})); -jest.mock('@sentry/react-native', () => ({captureException: jest.fn()})); +jest.mock('@sentry/react-native', () => ({captureException: jest.fn(), setUser: jest.fn()})); -jest.mock('@libs/Log', () => ({warn: jest.fn()})); +jest.mock('@libs/Log', () => ({warn: jest.fn(), info: jest.fn()})); // Every style lookup returns an empty object so the real Text/View render without a ThemeProvider. jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); diff --git a/tests/unit/hooks/useInsightsFilters.test.ts b/tests/unit/hooks/useInsightsFilters.test.ts index c342c2963900..a355370173cb 100644 --- a/tests/unit/hooks/useInsightsFilters.test.ts +++ b/tests/unit/hooks/useInsightsFilters.test.ts @@ -1,5 +1,7 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; +import * as UseOnyxModule from '@hooks/useOnyx'; + import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import {buildInsightsQueryString} from '@pages/Insights/insightsQueries'; @@ -52,6 +54,10 @@ describe('useInsightsFilters', () => { await waitForBatchedUpdates(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('opens on the page defaults, in the default workspace currency', async () => { // Given a user whose default workspace reports in PLN, and no stored selections await setUpActivePolicy('PLN'); @@ -113,12 +119,18 @@ describe('useInsightsFilters', () => { }); it('waits for the stored selections before saying it has resolved', async () => { - // Given Onyx with nothing loaded yet + // Given Onyx has not hydrated its cache yet + const useOnyxSpy = jest.spyOn(UseOnyxModule, 'default').mockReturnValue([undefined, {status: 'loading'}]); // When the dashboard first asks for its filters - const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + const {result, rerender} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); // Then it reports as unresolved, so the page doesn't request a dashboard the user never asked for expect(result.current.isResolved).toBe(false); + + // When hydration finishes without stored selections, the defaults can safely be used + useOnyxSpy.mockReturnValue([undefined, {status: 'loaded'}]); + rerender({}); + expect(result.current.isResolved).toBe(true); }); it('stores a control’s selection alongside the rest of the dashboard’s filters', async () => { diff --git a/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts b/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts index 67f88ed9fd72..4409ac911dc6 100644 --- a/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts +++ b/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts @@ -68,7 +68,7 @@ describe('useMoneyRequestParticipantsPolicyTags', () => { personalDetails, conciergeReportID: 'concierge1', privateIsArchived: false, - rules: undefined, + rules: {}, reportAttributesDerived, reportDraft, translate, diff --git a/tests/unit/onyxDerivedComputeSpanTest.ts b/tests/unit/onyxDerivedComputeSpanTest.ts index 9342fd31acb7..0c191e16631b 100644 --- a/tests/unit/onyxDerivedComputeSpanTest.ts +++ b/tests/unit/onyxDerivedComputeSpanTest.ts @@ -17,6 +17,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@sentry/react-native', () => { const startInactiveSpanCalls: unknown[] = []; return { + setUser: jest.fn(), startInactiveSpan: (options: {op?: string}) => { startInactiveSpanCalls.push(options); return { From 34d27373f517a2c5c179a25199c0f947fe985965 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 19:02:49 +0100 Subject: [PATCH 08/15] test(onyx): wait for report hydration before confirming expenses --- .../ui/components/IOURequestStepConfirmationPageTest.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 1bee9556c075..0c855dbab09e 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -1617,6 +1617,7 @@ describe('IOURequestStepConfirmationPageTest', () => { }); it('should use the transaction report when it is allowed, even if it differs from the route', async () => { + // Given an allowed transaction report that differs from the route report const routeReportID = '102'; const transactionReportID = '202'; const transactionID = 'tx-3'; @@ -1627,7 +1628,7 @@ describe('IOURequestStepConfirmationPageTest', () => { harvesting: {enabled: false}, }; - const canAddTransactionSpy = jest.spyOn(require('@libs/ReportUtils'), 'canAddTransaction').mockReturnValue(true); + const canAddTransactionSpy = jest.spyOn(ReportUtils, 'canAddTransaction').mockReturnValue(true); try { await act(async () => { @@ -1682,9 +1683,13 @@ describe('IOURequestStepConfirmationPageTest', () => { , ); - await waitForBatchedUpdatesWithAct(); + // When the transaction report has reached the selector, confirm the expense + await waitFor(() => { + expect(canAddTransactionSpy.mock.calls.some(([candidate]) => candidate?.reportID === transactionReportID)).toBe(true); + }); fireEvent.press(await screen.findByText(getConfirmButtonRegex())); + // Then the request uses the allowed transaction report instead of the route report await waitFor(() => expect(TrackExpense.requestMoney).toHaveBeenCalled()); const requestMoneyMock = jest.mocked(TrackExpense.requestMoney); const params = requestMoneyMock.mock.calls.at(0)?.at(0); From d0c1873b9f4e9cdf641e6ce17669d045cfc2b5ae Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 19:33:17 +0100 Subject: [PATCH 09/15] fix(onyx): preserve derived values with the updated SQLite provider Pin Onyx 6ee48a7f and reuse the derived reference-stability fixes and tests from Expensify/App#98956. --- package-lock.json | 6 +- package.json | 2 +- .../OnyxDerived/configs/guideAccountIDs.ts | 6 +- .../configs/loginToAccountIDMap.ts | 6 +- .../reportTransactionsAndViolations.ts | 4 +- src/libs/actions/OnyxDerived/index.ts | 3 + ...eesCellPersonalDetailsWrites.perf-test.tsx | 93 +++++++++++ ...arLinksPersonalDetailsWrites.perf-test.tsx | 110 +++++++++++++ tests/unit/OnyxDerived/guideAccountIDsTest.ts | 32 ++++ .../OnyxDerived/loginToAccountIDMapTest.ts | 26 +++ .../reportTransactionsAndViolationsTest.ts | 33 ++++ tests/unit/OnyxDerivedTest.tsx | 153 +++++++++++++++++- 12 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 tests/perf-test/AttendeesCellPersonalDetailsWrites.perf-test.tsx create mode 100644 tests/perf-test/SidebarLinksPersonalDetailsWrites.perf-test.tsx diff --git a/package-lock.json b/package-lock.json index 91a5f4115cb8..eaa134b5c89c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,7 +125,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#12b7b383b4ac6900538ebce85968704496307626", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6ee48a7f8e3e6e120d6e018aa14d6bddffb77b51", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -37296,8 +37296,8 @@ }, "node_modules/react-native-onyx": { "version": "3.0.119", - "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#12b7b383b4ac6900538ebce85968704496307626", - "integrity": "sha512-JQIilqj097+2y2diBesMIyITSjKxYuT7KSdVY2cLPYKTC2RT8t7G7W/bKd1dnQbaenmhpnY/zH4Dhx3eYj4geg==", + "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#6ee48a7f8e3e6e120d6e018aa14d6bddffb77b51", + "integrity": "sha512-uTIZPMq4ogBsvMlo55Fc82G1BcSe1txopT+yoCwUwGgJsMK7JCr2J0ezWuU2eJx0gQvogM/nU7ixyL/sPGRloA==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index 8ad03d514f7f..d02ea87eb7f8 100644 --- a/package.json +++ b/package.json @@ -201,7 +201,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#12b7b383b4ac6900538ebce85968704496307626", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6ee48a7f8e3e6e120d6e018aa14d6bddffb77b51", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", diff --git a/src/libs/actions/OnyxDerived/configs/guideAccountIDs.ts b/src/libs/actions/OnyxDerived/configs/guideAccountIDs.ts index 45b760a68be4..76805965dc36 100644 --- a/src/libs/actions/OnyxDerived/configs/guideAccountIDs.ts +++ b/src/libs/actions/OnyxDerived/configs/guideAccountIDs.ts @@ -5,6 +5,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {GuideAccountIDsDerivedValue} from '@src/types/onyx'; import {Str} from 'expensify-common'; +import {shallowEqual} from 'fast-equals'; /** * Derives the accountIDs of every Expensify Guide (identified by login domain) in the personal details list. @@ -13,7 +14,7 @@ import {Str} from 'expensify-common'; export default createOnyxDerivedValueConfig({ key: ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS, dependencies: [ONYXKEYS.PERSONAL_DETAILS_LIST], - compute: ([personalDetailsList]) => { + compute: ([personalDetailsList], {currentValue}) => { if (!personalDetailsList) { return []; } @@ -25,6 +26,7 @@ export default createOnyxDerivedValueConfig({ } guideAccountIDs.push(personalDetails.accountID); } - return guideAccountIDs.sort((a, b) => a - b); + guideAccountIDs.sort((a, b) => a - b); + return currentValue && shallowEqual(currentValue, guideAccountIDs) ? currentValue : guideAccountIDs; }, }); diff --git a/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts b/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts index 5c0fc83521d5..5e8b422ae2b5 100644 --- a/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts +++ b/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts @@ -3,6 +3,8 @@ import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDer import ONYXKEYS from '@src/ONYXKEYS'; import type {LoginToAccountIDMapDerivedValue} from '@src/types/onyx'; +import {shallowEqual} from 'fast-equals'; + /** * Derives a map of login (lowercased) -> accountID from the personal details list. * @@ -13,7 +15,7 @@ import type {LoginToAccountIDMapDerivedValue} from '@src/types/onyx'; export default createOnyxDerivedValueConfig({ key: ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP, dependencies: [ONYXKEYS.PERSONAL_DETAILS_LIST], - compute: ([personalDetailsList]) => { + compute: ([personalDetailsList], {currentValue}) => { if (!personalDetailsList) { return {}; } @@ -30,6 +32,6 @@ export default createOnyxDerivedValueConfig({ loginToAccountIDMap[login] = personalDetails.accountID; } } - return loginToAccountIDMap; + return currentValue && shallowEqual(currentValue, loginToAccountIDMap) ? currentValue : loginToAccountIDMap; }, }); diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index e56bd7d756ca..e1432f9013a4 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -5,6 +5,8 @@ import type {TransactionViolation} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; +import {shallowEqual} from 'fast-equals'; + let previousViolations: OnyxCollection = {}; let transactionReportIDMapping: Record = {}; @@ -117,7 +119,7 @@ export default createOnyxDerivedValueConfig({ previousViolations = violations; - return reportTransactionsAndViolations; + return currentValue && shallowEqual(currentValue, reportTransactionsAndViolations) ? currentValue : reportTransactionsAndViolations; }, // On cache clear, drop the cross-compute state so the map is rebuilt from scratch (see the engine's resetForClear). onReset: () => { diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index cfe1ec657075..8c12496c2730 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -146,6 +146,9 @@ function init() { try { // @ts-expect-error TypeScript can't confirm the shape of dependencyValues matches the compute function's parameters const newDerivedValue = compute(dependencyValues, context); + if (newDerivedValue === derivedValue) { + return; + } Log.info(`[OnyxDerived] updating value for ${key} in Onyx`); derivedValue = newDerivedValue; setDerivedValue(key, derivedValue); diff --git a/tests/perf-test/AttendeesCellPersonalDetailsWrites.perf-test.tsx b/tests/perf-test/AttendeesCellPersonalDetailsWrites.perf-test.tsx new file mode 100644 index 000000000000..e1e99f8c7e6c --- /dev/null +++ b/tests/perf-test/AttendeesCellPersonalDetailsWrites.perf-test.tsx @@ -0,0 +1,93 @@ +import {screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import AttendeesCell from '@components/Search/SearchList/ListItem/AttendeesCell'; + +import initOnyxDerivedValues from '@userActions/OnyxDerived'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import React from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import {measureRenders} from 'reassure'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const ROW_COUNT = 50; +const ATTENDEES_PER_ROW = 3; +const WRITES_PER_SCENARIO = 10; +const UNRELATED_ACCOUNT_ID = 1000; + +const personalDetails: PersonalDetailsList = { + [UNRELATED_ACCOUNT_ID]: {accountID: UNRELATED_ACCOUNT_ID, login: 'unrelated@test.com', displayName: 'Unrelated'}, +}; +const rows: Attendee[][] = []; +for (let row = 0; row < ROW_COUNT; row++) { + const attendees: Attendee[] = []; + for (let index = 0; index < ATTENDEES_PER_ROW; index++) { + const accountID = row * ATTENDEES_PER_ROW + index + 1; + const email = `attendee${accountID}@test.com`; + personalDetails[accountID] = {accountID, login: email, displayName: `Attendee ${accountID}`}; + attendees.push({email, displayName: `Attendee ${accountID}`, avatarUrl: ''}); + } + rows.push(attendees); +} + +function SearchRowsWithAttendees() { + return ( + + + {rows.map((attendees, index) => ( + + ))} + + + ); +} + +describe('AttendeesCell on personal details writes', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + initOnyxDerivedValues(); + }); + + beforeEach(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[AttendeesCell] should re-render 50 Search rows when a user who attends nothing is renamed', async () => { + // Reassure runs the scenario several times against one seeded Onyx, so every run writes a name that doesn't exist yet + let run = 0; + const scenario = async () => { + // Given 50 Search rows with attendees have rendered + await screen.findAllByTestId('AttendeesCell-Row'); + + // When a user who attends none of them is renamed, which makes the engine recompute the login map to the same content + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[UNRELATED_ACCOUNT_ID]: {displayName: `Unrelated ${run}-${index}`}}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures how many row re-renders the unrelated writes cause + await measureRenders(, {scenario}); + }); +}); diff --git a/tests/perf-test/SidebarLinksPersonalDetailsWrites.perf-test.tsx b/tests/perf-test/SidebarLinksPersonalDetailsWrites.perf-test.tsx new file mode 100644 index 000000000000..032d3602aef0 --- /dev/null +++ b/tests/perf-test/SidebarLinksPersonalDetailsWrites.perf-test.tsx @@ -0,0 +1,110 @@ +import {screen} from '@testing-library/react-native'; + +import {setHasRadio} from '@libs/NetworkState'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; +import {measureRenders} from 'reassure'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@libs/Permissions'); +jest.mock('../../src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + isActiveRoute: jest.fn(), + getTopmostReportId: jest.fn(), + getActiveRoute: jest.fn(), + getTopmostReportActionId: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isDisplayedInModal: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); +jest.mock('../../src/libs/Navigation/navigationRef', () => ({ + getState: () => ({ + routes: [{name: 'Report'}], + }), + getRootState: () => ({ + routes: [], + }), + addListener: () => () => {}, + isReady: () => true, +})); + +jest.mock('@react-navigation/native'); + +const REPORT_COUNT = 500; +const WRITES_PER_SCENARIO = 10; +const GUIDE_ACCOUNT_ID = 50; +const UNRELATED_ACCOUNT_ID = 60; + +const reports = Object.fromEntries( + Array.from({length: REPORT_COUNT}, (value, index) => { + const report = {...LHNTestUtils.getFakeReport([1, 2], 1, true), lastMessageText: 'hey'}; + return [`${ONYXKEYS.COLLECTION.REPORT}${index + 1}`, report]; + }), +); + +const personalDetails: PersonalDetailsList = { + ...LHNTestUtils.fakePersonalDetails, + [GUIDE_ACCOUNT_ID]: {accountID: GUIDE_ACCOUNT_ID, login: `guide@${CONST.EMAIL.GUIDES_DOMAIN}`, displayName: 'Guide'}, + [UNRELATED_ACCOUNT_ID]: {accountID: UNRELATED_ACCOUNT_ID, login: 'unrelated@test.com', displayName: 'Unrelated'}, +}; + +describe('SidebarLinks on personal details writes', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // Required lazily so LHNTestUtils registers its @react-navigation/native mock before the engine's imports load it + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + setHasRadio(true); + await TestHelper.signInWithTestUser(1, 'email1@test.com', undefined, undefined, 'One'); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[SidebarLinks] should re-render when a user outside the LHN is renamed', async () => { + // Given 500 reports in the LHN, a guide, and a user who is in none of the reports + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + ...reports, + }); + await waitForBatchedUpdates(); + + // Reassure runs the scenario several times against one seeded Onyx, so every run writes a name that doesn't exist yet + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-options-list'); + + // When that user is renamed repeatedly, which makes the derived engine recompute the guide accountIDs to the same content + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[UNRELATED_ACCOUNT_ID]: {displayName: `Unrelated ${run}-${index}`}}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures what the unrelated writes cost the LHN + await measureRenders(, {scenario}); + }); +}); diff --git a/tests/unit/OnyxDerived/guideAccountIDsTest.ts b/tests/unit/OnyxDerived/guideAccountIDsTest.ts index fd8e7d6ff91f..01ad4c581d00 100644 --- a/tests/unit/OnyxDerived/guideAccountIDsTest.ts +++ b/tests/unit/OnyxDerived/guideAccountIDsTest.ts @@ -47,4 +47,36 @@ describe('guideAccountIDs', () => { expect(guideAccountIDsConfig.compute([guideListedLast], {})).toEqual([otherGuideAccountID, guideAccountID]); expect(guideAccountIDsConfig.compute([sameGuidesPlusAnAvatarChange], {})).toEqual([otherGuideAccountID, guideAccountID]); }); + + it('should return the current value when the guides are unchanged', () => { + // Given the guide accountIDs computed from the current personal details + const personalDetailsList: PersonalDetailsList = { + [guideAccountID]: {accountID: guideAccountID, login: guideLogin}, + [memberAccountID]: {accountID: memberAccountID, login: 'member@example.com'}, + }; + const currentValue = guideAccountIDsConfig.compute([personalDetailsList], {}); + + // When a member changes their name, which leaves the set of guides as it was + const renamedMember: PersonalDetailsList = {...personalDetailsList, [memberAccountID]: {accountID: memberAccountID, login: 'member@example.com', displayName: 'Renamed'}}; + + // Then the same array comes back, so consumers comparing by reference don't re-render + expect(guideAccountIDsConfig.compute([renamedMember], {currentValue})).toBe(currentValue); + }); + + it('should return a new array when a guide is removed', () => { + // Given two guides + const twoGuides: PersonalDetailsList = { + [guideAccountID]: {accountID: guideAccountID, login: guideLogin}, + [otherGuideAccountID]: {accountID: otherGuideAccountID, login: otherGuideLogin}, + }; + const currentValue = guideAccountIDsConfig.compute([twoGuides], {}); + + // When one of them leaves the personal details + const oneGuide: PersonalDetailsList = {[guideAccountID]: {accountID: guideAccountID, login: guideLogin}}; + const result = guideAccountIDsConfig.compute([oneGuide], {currentValue}); + + // Then a shorter list must not be mistaken for the current one + expect(result).not.toBe(currentValue); + expect(result).toEqual([guideAccountID]); + }); }); diff --git a/tests/unit/OnyxDerived/loginToAccountIDMapTest.ts b/tests/unit/OnyxDerived/loginToAccountIDMapTest.ts index c781a6644a9d..2da16bdd6f6e 100644 --- a/tests/unit/OnyxDerived/loginToAccountIDMapTest.ts +++ b/tests/unit/OnyxDerived/loginToAccountIDMapTest.ts @@ -38,4 +38,30 @@ describe('loginToAccountIDMap', () => { expect(loginToAccountIDMapConfig.compute([optimisticHasLowerAccountID], {})).toEqual({[login]: accountID2}); }); + + it('should return the current value when the logins are unchanged', () => { + // Given the map computed from the current personal details + const personalDetailsList: PersonalDetailsList = {[accountID1]: {accountID: accountID1, login}}; + const currentValue = loginToAccountIDMapConfig.compute([personalDetailsList], {}); + + // When the user changes their name, which keeps every login on the same accountID + const renamed: PersonalDetailsList = {[accountID1]: {accountID: accountID1, login, displayName: 'Renamed'}}; + + // Then the same map comes back, so consumers comparing by reference don't re-render + expect(loginToAccountIDMapConfig.compute([renamed], {currentValue})).toBe(currentValue); + }); + + it('should return a new map when a login moves to another accountID', () => { + // Given a login that only has an optimistic personal detail so far + const optimistic: PersonalDetailsList = {[accountID1]: {accountID: accountID1, login, isOptimisticPersonalDetail: true}}; + const currentValue = loginToAccountIDMapConfig.compute([optimistic], {}); + + // When the server replaces it with the real account, so the key stays and only its value changes + const real: PersonalDetailsList = {[accountID2]: {accountID: accountID2, login}}; + const result = loginToAccountIDMapConfig.compute([real], {currentValue}); + + // Then the change must be detected, or attendees would keep pointing at the optimistic account + expect(result).not.toBe(currentValue); + expect(result).toEqual({[login]: accountID2}); + }); }); diff --git a/tests/unit/OnyxDerived/reportTransactionsAndViolationsTest.ts b/tests/unit/OnyxDerived/reportTransactionsAndViolationsTest.ts index 3f8609498f74..5013909e18e4 100644 --- a/tests/unit/OnyxDerived/reportTransactionsAndViolationsTest.ts +++ b/tests/unit/OnyxDerived/reportTransactionsAndViolationsTest.ts @@ -112,4 +112,37 @@ describe('reportTransactionsAndViolations compute', () => { expect(result.reportF?.transactions?.[txKey('8')]).toBeUndefined(); expect(result.reportF?.transactions?.[txKey('9')]).toBeTruthy(); }); + + it('should return the current value when the delta touches no stored transaction', () => { + // Given a derived value holding one transaction + const transactions: OnyxCollection = {[txKey('10')]: makeTx('10', 'reportG')}; + const currentValue: ReportTransactionsAndViolationsDerivedValue = {reportG: {transactions: {[txKey('10')]: makeTx('10', 'reportG')}, violations: {}}}; + + // When a violation lands for a transaction that isn't in the collection + const violations: OnyxCollection = {[violationsKey('unknown')]: [dupViolation]}; + const result = reportTransactionsAndViolationsConfig.compute([transactions, violations], { + sourceValues: {[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: violations}, + currentValue, + }); + + // Then the same object comes back, so consumers comparing by reference don't re-render + expect(result).toBe(currentValue); + }); + + it('should return a new object when the delta changes a stored transaction', () => { + // Given a derived value holding one transaction + const transactions: OnyxCollection = {[txKey('11')]: makeTx('11', 'reportH')}; + const currentValue: ReportTransactionsAndViolationsDerivedValue = {reportH: {transactions: {[txKey('11')]: makeTx('11', 'reportH')}, violations: {}}}; + + // When that transaction gets a violation + const violations: OnyxCollection = {[violationsKey('11')]: [dupViolation]}; + const result = reportTransactionsAndViolationsConfig.compute([transactions, violations], { + sourceValues: {[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: violations}, + currentValue, + }); + + // Then a new object carries the violation instead of the current value being reused + expect(result).not.toBe(currentValue); + expect(result.reportH?.violations?.[violationsKey('11')]).toHaveLength(1); + }); }); diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index 374c980c558b..a1c8c8b613f6 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -1,5 +1,10 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useOnyx from '@hooks/useOnyx'; + import reportAttributes from '@libs/actions/OnyxDerived/configs/reportAttributes'; import {isReportActionVisible} from '@libs/ReportActionsUtils'; +import * as ActiveSpans from '@libs/telemetry/activeSpans'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import * as OnyxDerivedUtils from '@userActions/OnyxDerived/utils'; @@ -7,9 +12,10 @@ import * as OnyxDerivedUtils from '@userActions/OnyxDerived/utils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, Transaction, TransactionViolation, ReportAction, ReportActions} from '@src/types/onyx'; +import type {PersonalDetailsList, Report, Transaction, TransactionViolation, ReportAction, ReportActions} from '@src/types/onyx'; import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; /* eslint-disable @typescript-eslint/naming-convention */ import Onyx from 'react-native-onyx'; @@ -18,14 +24,41 @@ import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import {createRandomCompanyCard, createRandomExpensifyCard} from '../utils/collections/card'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; import {createMockReport, getFakeReportAction} from '../utils/ReportTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const onyxDerivedTestSetup = () => { Onyx.init({keys: ONYXKEYS}); initOnyxDerivedValues(); }; +const GUIDE_ACCOUNT_ID = 8; +const MEMBER_ACCOUNT_ID = 1; +const NEW_GUIDE_ACCOUNT_ID = 9; +const NEW_MEMBER_ACCOUNT_ID = 2; + +const PERSONAL_DETAILS: PersonalDetailsList = { + [GUIDE_ACCOUNT_ID]: {accountID: GUIDE_ACCOUNT_ID, login: `guide@${CONST.EMAIL.GUIDES_DOMAIN}`, displayName: 'Guide'}, + [MEMBER_ACCOUNT_ID]: {accountID: MEMBER_ACCOUNT_ID, login: 'member@example.com', displayName: 'Member'}, +}; + +const KEYS_READ_WITHOUT_SELECTOR = [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS, ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP] as const; + +function renderHookWithoutSelector>(key: TKey) { + let renderCount = 0; + const hook = renderHook(() => { + renderCount++; + return useOnyx(key); + }); + return {hook, getRenderCount: () => renderCount}; +} + +function getGuideWrites(setDerivedValueSpy: jest.Spied) { + return setDerivedValueSpy.mock.calls.filter(([key]) => key === ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); +} + describe('OnyxDerived', () => { beforeAll(async () => { onyxDerivedTestSetup(); @@ -1021,4 +1054,122 @@ describe('OnyxDerived', () => { }); }); }); + + describe('skipping the write when compute returns the current value', () => { + beforeEach(async () => { + jest.restoreAllMocks(); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS); + await waitForBatchedUpdates(); + }); + + it('should not write to Onyx when compute returns the current value', async () => { + // Given the guide accountIDs have been computed and stored + const setDerivedValueSpy = jest.spyOn(OnyxDerivedUtils, 'setDerivedValue'); + + // When a member is renamed, which recomputes the guides to the value already held + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[MEMBER_ACCOUNT_ID]: {displayName: 'Renamed member'}}); + await waitForBatchedUpdates(); + + // Then nothing is written, so subscribers aren't notified and the value isn't rewritten to disk + expect(getGuideWrites(setDerivedValueSpy)).toHaveLength(0); + expect(await getOnyxValue(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS)).toEqual([GUIDE_ACCOUNT_ID]); + }); + + it('should still end the compute span when the write is skipped', async () => { + // Given the compute span is observed + const endSpanSpy = jest.spyOn(ActiveSpans, 'endSpan'); + + // When a recompute returns the current value and skips the write + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[MEMBER_ACCOUNT_ID]: {displayName: 'Renamed member'}}); + await waitForBatchedUpdates(); + + // Then the early return still goes through finally, so no compute span is left open + expect(endSpanSpy).toHaveBeenCalledWith(`${CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE}_${ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS}`); + }); + + it('should rewrite the value after Onyx.clear', async () => { + // Given the guide accountIDs are stored and Onyx is then cleared, as on sign-out + await Onyx.clear(); + await waitForBatchedUpdates(); + const setDerivedValueSpy = jest.spyOn(OnyxDerivedUtils, 'setDerivedValue'); + + // When the same personal details come back, as on signing in again + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS); + await waitForBatchedUpdates(); + + // Then the engine writes the value again instead of treating the pre-clear value as current + expect(getGuideWrites(setDerivedValueSpy).length).toBeGreaterThan(0); + expect(await getOnyxValue(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS)).toEqual([GUIDE_ACCOUNT_ID]); + }); + }); + + describe('consumers without a selector on PERSONAL_DETAILS_LIST writes', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, false); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it.each(KEYS_READ_WITHOUT_SELECTOR)('should keep the %s reference when an unrelated personal detail changes', async (key) => { + // Given a component that reads the derived value without a selector, the way 10 App sites do + const {hook, getRenderCount} = renderHookWithoutSelector(key); + await waitForBatchedUpdatesWithAct(); + const [valueBefore] = hook.result.current; + const rendersBefore = getRenderCount(); + + // When a member renames themselves, which leaves both derived values with the same content + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[MEMBER_ACCOUNT_ID]: {displayName: 'Renamed member'}}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the engine's fresh but equal result must not reach the consumer as a new reference or a re-render + expect(hook.result.current[0]).toBe(valueBefore); + expect(getRenderCount()).toBe(rendersBefore); + }); + + it('should return new guide accountIDs when a guide is added', async () => { + // Given a consumer of the guide accountIDs + const {hook} = renderHookWithoutSelector(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); + await waitForBatchedUpdatesWithAct(); + const [valueBefore] = hook.result.current; + + // When a second guide's personal details arrive + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [NEW_GUIDE_ACCOUNT_ID]: {accountID: NEW_GUIDE_ACCOUNT_ID, login: `new.guide@${CONST.EMAIL.GUIDES_DOMAIN}`}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the consumer sees the change, so the stability check above is not passing on a frozen value + expect(hook.result.current[0]).not.toBe(valueBefore); + expect(hook.result.current[0]).toEqual([GUIDE_ACCOUNT_ID, NEW_GUIDE_ACCOUNT_ID]); + }); + + it('should return a new login map when a login is added', async () => { + // Given a consumer of the login to accountID map + const {hook} = renderHookWithoutSelector(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); + await waitForBatchedUpdatesWithAct(); + const [valueBefore] = hook.result.current; + + // When a new member's personal details arrive + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [NEW_MEMBER_ACCOUNT_ID]: {accountID: NEW_MEMBER_ACCOUNT_ID, login: 'new.member@example.com'}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the consumer sees the new login + expect(hook.result.current[0]).not.toBe(valueBefore); + expect(hook.result.current[0]?.['new.member@example.com']).toBe(NEW_MEMBER_ACCOUNT_ID); + }); + }); }); From 7f6383859aaa38a1cb7d384ef92461899e75afdc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 16 Oct 2026 15:02:01 +0100 Subject: [PATCH 10/15] chore(deps): pin latest Onyx SQLite provider fixes --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index eaa134b5c89c..e9fb1921a89e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,7 +125,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6ee48a7f8e3e6e120d6e018aa14d6bddffb77b51", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#0e91aa5d8572f9812aa21e48ee17eba549404a35", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -37296,8 +37296,8 @@ }, "node_modules/react-native-onyx": { "version": "3.0.119", - "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#6ee48a7f8e3e6e120d6e018aa14d6bddffb77b51", - "integrity": "sha512-uTIZPMq4ogBsvMlo55Fc82G1BcSe1txopT+yoCwUwGgJsMK7JCr2J0ezWuU2eJx0gQvogM/nU7ixyL/sPGRloA==", + "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#0e91aa5d8572f9812aa21e48ee17eba549404a35", + "integrity": "sha512-JTaWdV1/KIaEpmpt4Pn3qKayNYDlIVNaFdU6dQ0TCPEWvD0uijpZKOfyGKajrnXRpkQRdNdbUxBp6SqboBIihg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index d02ea87eb7f8..0084787f11a6 100644 --- a/package.json +++ b/package.json @@ -201,7 +201,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#6ee48a7f8e3e6e120d6e018aa14d6bddffb77b51", + "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#0e91aa5d8572f9812aa21e48ee17eba549404a35", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From c39c62735abb8d889b1e42eeeacee31035de5065 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 12:29:47 +0100 Subject: [PATCH 11/15] chore(deps): update Onyx to 3.0.121 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 129bc857b9bd..6aee7155dbf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,7 +126,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#0e91aa5d8572f9812aa21e48ee17eba549404a35", + "react-native-onyx": "3.0.121", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -37325,9 +37325,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.119", - "resolved": "git+ssh://git@github.com/margelo/react-native-onyx.git#0e91aa5d8572f9812aa21e48ee17eba549404a35", - "integrity": "sha512-uTIZPMq4ogBsvMlo55Fc82G1BcSe1txopT+yoCwUwGgJsMK7JCr2J0ezWuU2eJx0gQvogM/nU7ixyL/sPGRloA==", + "version": "3.0.121", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.121.tgz", + "integrity": "sha512-l45OcRFhA/2WDU9O5GYopD6MpRwvdi6ARiTwzE2xuflOhBBHPimbZXQWK0pCp1tKaf4+8mIKEkxNUi1+TYaVQA==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index b481b491cc26..60d52a465744 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "10.2.0", - "react-native-onyx": "git+https://github.com/margelo/react-native-onyx.git#0e91aa5d8572f9812aa21e48ee17eba549404a35", + "react-native-onyx": "3.0.121", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From d5d03c3fd6827c5a92d79d58aa4bc57a752f6037 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 12:45:55 +0100 Subject: [PATCH 12/15] chore(onyx): remove obsolete test adjustments --- tests/actions/IOU/MoneyRequestSettersTest.ts | 3 +- tests/actions/IOU/RequestMoneyTest.ts | 120 +++++++++++------- tests/actions/ReportTest.ts | 2 - tests/actions/SessionTest.ts | 45 ++++--- tests/actions/TransactionEditTest.ts | 4 - .../IOURequestStepConfirmationPageTest.tsx | 9 +- tests/unit/CategoryTagPickerAutoFocusTest.tsx | 4 - tests/unit/TelemetrySynchronizerTest.ts | 2 +- tests/unit/hooks/useInsightsFilters.test.ts | 26 ++-- 9 files changed, 113 insertions(+), 102 deletions(-) diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index acb160ad7e79..69f1eec529d2 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -697,8 +697,7 @@ describe('actions/IOU', () => { describe('resetDraftTransactionsCustomUnit', () => { it('should do nothing if transaction is not passed', async () => { - // Given an empty draft collection - // When the reset function is called without a transaction + // Call the reset function without a transaction resetDraftTransactionsCustomUnit(undefined); await waitForBatchedUpdates(); const allDraftTransactions = await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT); diff --git a/tests/actions/IOU/RequestMoneyTest.ts b/tests/actions/IOU/RequestMoneyTest.ts index 95f6b0bedfc2..df676a91ae2f 100644 --- a/tests/actions/IOU/RequestMoneyTest.ts +++ b/tests/actions/IOU/RequestMoneyTest.ts @@ -1,5 +1,3 @@ -import {waitFor} from '@testing-library/react-native'; - import type {SearchQueryJSON} from '@components/Search/types'; import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; @@ -411,14 +409,21 @@ describe('actions/IOU', () => { }), ) .then(mockFetch?.resume) - .then(() => - waitFor(async () => { - const reportActionsForIOUReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - }), + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), ) .then( () => @@ -599,14 +604,21 @@ describe('actions/IOU', () => { ) .then(mockFetch?.resume) .then(waitForBatchedUpdates) - .then(() => - waitFor(async () => { - const reportActionsForIOUReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - }), + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), ) .then( () => @@ -814,14 +826,21 @@ describe('actions/IOU', () => { ) .then(mockFetch?.resume) .then(waitForNetworkPromises) - .then(() => - waitFor(async () => { - const reportActionsForIOUReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - }), + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), ) .then( () => @@ -1029,13 +1048,20 @@ describe('actions/IOU', () => { }); }), ) - .then(() => - waitFor(async () => { - const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(transaction?.errors).toBeTruthy(); - expect(Object.values(transaction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.genericCreateFailureMessage')); - }), + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(transaction?.errors).toBeTruthy(); + expect(Object.values(transaction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.genericCreateFailureMessage')); + resolve(); + }, + }); + }), ) // If the user clears the errors on the IOU action @@ -1355,14 +1381,14 @@ describe('actions/IOU', () => { mockFetch?.fail?.(); await (mockFetch?.resume?.() as Promise); - // Then the failed request adds the workspace invitation error to the new accountant - await waitFor(async () => { - const policyData = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); - const accountantEmployee = policyData?.employeeList?.[accountant.email]; - expect(accountantEmployee).toBeTruthy(); - expect(accountantEmployee?.errors).toBeTruthy(); - expect(Object.values(accountantEmployee?.errors ?? {}).at(0)).toEqual(translateLocal('workspace.people.error.genericAdd')); - }); + // Verify error handling after failure - focus on workspace invitation error + const policyData = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + + // The new accountant should have been added to the employee list with error + const accountantEmployee = policyData?.employeeList?.[accountant.email]; + expect(accountantEmployee).toBeTruthy(); + expect(accountantEmployee?.errors).toBeTruthy(); + expect(Object.values(accountantEmployee?.errors ?? {}).at(0)).toEqual(translateLocal('workspace.people.error.genericAdd')); // Cleanup mockFetch?.succeed?.(); @@ -3262,10 +3288,8 @@ describe('actions/IOU', () => { createTransaction(buildCreateTransactionParams(CONST.IOU.TYPE.TRACK, selfDMReport)); await waitForBatchedUpdates(); - await waitFor(() => { - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 1); - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 0); - }); + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 1); + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 0); }); it('routes the submit flow through requestMoney', async () => { @@ -3279,10 +3303,8 @@ describe('actions/IOU', () => { createTransaction(buildCreateTransactionParams(CONST.IOU.TYPE.SUBMIT, chatReport)); await waitForBatchedUpdates(); - await waitFor(() => { - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 1); - expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 0); - }); + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REQUEST_MONEY, 1); + expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.TRACK_EXPENSE, 0); }); }); }); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index e760ee3a7cec..443620e8ea64 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -6688,10 +6688,8 @@ describe('actions/Report', () => { }); it('should not set anything if no reportID was provided', async () => { - // Given no report to attach the transaction thread to const reportID = undefined; - // When the thread creation is requested Report.setOptimisticTransactionThread(reportID); await waitForBatchedUpdates(); diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index 30aa477155b6..a0c4e33edc20 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -1,7 +1,6 @@ /* eslint-disable rulesdir/no-multiple-api-calls */ // cspell:ignore SOMESECRETKEY import {beforeEach, jest, test} from '@jest/globals'; -import {waitFor} from '@testing-library/react-native'; import {openApp, reconnectApp} from '@libs/actions/App'; import * as DelegateActions from '@libs/actions/Delegate'; @@ -9,7 +8,7 @@ import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {getAll as getAllPersistedRequests} from '@libs/actions/PersistedRequests'; import {initReconnect} from '@libs/actions/Reconnect'; import * as SignInRedirect from '@libs/actions/SignInRedirect'; -import {AUTHENTICATION_COMMAND, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import asyncOpenURL from '@libs/asyncOpenURL'; import buildOldDotURL from '@libs/buildOldDotURL'; import getPlatform from '@libs/getPlatform'; @@ -341,15 +340,18 @@ describe('Session', () => { expect(session?.accountID).toBe(TEST_USER_ACCOUNT_ID); expect(session?.email).toBe(TEST_USER_LOGIN); - // Expire the next OpenApp response, then refresh the token through Authenticate. - // Match by command so background requests cannot consume those responses. - let hasExpiredSession = false; - jest.mocked(HttpUtils.xhr).mockImplementation((command) => { - if (command === WRITE_COMMANDS.OPEN_APP && !hasExpiredSession) { - hasExpiredSession = true; - return Promise.resolve({jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED}); - } - if (command === AUTHENTICATION_COMMAND) { + // At this point we have an authToken. To simulate it expiring we'll just make another + // request and mock the response so it returns 407. Once this happens we should attempt + // to Re-Authenticate with the stored credentials. Our next call will be to Authenticate + // so we will mock that response with a new authToken and then verify that Onyx has our + // data. + // Key off the command rather than the call order: `openApp()` fires LoadPersonalDetails on the + // main queue and OpenApp on the sequential queue, and which of them reaches the network first + // is not something this test is about. + let hasExpiredASession = false; + jest.mocked(HttpUtils.xhr).mockImplementation((command: string) => { + // Re-authenticating hands back a fresh authToken + if (command === 'Authenticate') { return Promise.resolve({ jsonCode: CONST.JSON_CODE.SUCCESS, accountID: TEST_USER_ACCOUNT_ID, @@ -357,18 +359,23 @@ describe('Session', () => { email: TEST_USER_LOGIN, }); } + + // The first app request after signing in finds the session expired + if (!hasExpiredASession) { + hasExpiredASession = true; + return Promise.resolve({jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED}); + } + return Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS}); }); - try { - // When we attempt to fetch the initial app data via the API - openApp(); + // When we attempt to fetch the initial app data via the API + openApp(); + await waitForBatchedUpdates(); - // Then reauthentication updates the session with the refreshed token - await waitFor(() => expect(session?.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN)); - } finally { - jest.mocked(HttpUtils.xhr).mockReset(); - } + // Then it should fail and reauthenticate the user adding the new authToken to the session + // data in Onyx + expect(session?.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN); }); test('Push notifications are subscribed after signing in', async () => { diff --git a/tests/actions/TransactionEditTest.ts b/tests/actions/TransactionEditTest.ts index 6a9303be36ed..d0089c344b00 100644 --- a/tests/actions/TransactionEditTest.ts +++ b/tests/actions/TransactionEditTest.ts @@ -71,8 +71,6 @@ describe('actions/TransactionEdit', () => { }); it('should handle null transaction gracefully', async () => { - // Given no transaction to back up - // When a backup is requested createBackupTransaction(undefined, false); await waitForBatchedUpdates(); @@ -134,8 +132,6 @@ describe('actions/TransactionEdit', () => { }); it('should handle null transactionID gracefully', async () => { - // Given no transaction ID to restore - // When a restore is requested restoreOriginalTransactionFromBackup(undefined, false); await waitForBatchedUpdates(); diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 05b7528637fd..a266452654a3 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -1624,7 +1624,6 @@ describe('IOURequestStepConfirmationPageTest', () => { }); it('should use the transaction report when it is allowed, even if it differs from the route', async () => { - // Given an allowed transaction report that differs from the route report const routeReportID = '102'; const transactionReportID = '202'; const transactionID = 'tx-3'; @@ -1635,7 +1634,7 @@ describe('IOURequestStepConfirmationPageTest', () => { harvesting: {enabled: false}, }; - const canAddTransactionSpy = jest.spyOn(ReportUtils, 'canAddTransaction').mockReturnValue(true); + const canAddTransactionSpy = jest.spyOn(require('@libs/ReportUtils'), 'canAddTransaction').mockReturnValue(true); try { await act(async () => { @@ -1690,13 +1689,9 @@ describe('IOURequestStepConfirmationPageTest', () => { , ); - // When the transaction report has reached the selector, confirm the expense - await waitFor(() => { - expect(canAddTransactionSpy.mock.calls.some(([candidate]) => candidate?.reportID === transactionReportID)).toBe(true); - }); + await waitForBatchedUpdatesWithAct(); fireEvent.press(await screen.findByText(getConfirmButtonRegex())); - // Then the request uses the allowed transaction report instead of the route report await waitFor(() => expect(TrackExpense.requestMoney).toHaveBeenCalled()); const requestMoneyMock = jest.mocked(TrackExpense.requestMoney); const params = requestMoneyMock.mock.calls.at(0)?.at(0); diff --git a/tests/unit/CategoryTagPickerAutoFocusTest.tsx b/tests/unit/CategoryTagPickerAutoFocusTest.tsx index 1cd8a02e84be..cda4c8ece815 100644 --- a/tests/unit/CategoryTagPickerAutoFocusTest.tsx +++ b/tests/unit/CategoryTagPickerAutoFocusTest.tsx @@ -38,10 +38,6 @@ jest.mock('@components/SelectionList/SelectionListWithSections', () => ({ jest.mock('@libs/canFocusInputOnScreenFocus'); const mockedCanFocusInputOnScreenFocus = jest.mocked(canFocusInputOnScreenFocus); -// Keep the autofocus test independent of the pickers' background policy fetches. -jest.mock('@hooks/useLoadPolicyCategories', () => jest.fn(() => ({isLoadingPolicyCategories: false}))); -jest.mock('@hooks/useLoadPolicyTags', () => jest.fn(() => ({isLoadingPolicyTags: false, hasLoadedPolicyTags: true}))); - // `useAutoFocusInput` (still wired into both pickers) pulls in navigation. Stub it like the closest existing // precedent, tests/unit/BaseSelectionListSectionsTest.tsx, so the effect never runs in jsdom. jest.mock('@react-navigation/native', () => { diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts index 83e0c7817c1d..4454e796ef65 100644 --- a/tests/unit/TelemetrySynchronizerTest.ts +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -16,9 +16,9 @@ import createRandomTransaction from '../utils/collections/transaction'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@sentry/react-native', () => ({ - setUser: jest.fn(), setTag: jest.fn(), setContext: jest.fn(), + setUser: jest.fn(), })); jest.mock('@libs/PolicyUtils', () => ({ diff --git a/tests/unit/hooks/useInsightsFilters.test.ts b/tests/unit/hooks/useInsightsFilters.test.ts index 7890b0e7b6fc..31b9863d7f5f 100644 --- a/tests/unit/hooks/useInsightsFilters.test.ts +++ b/tests/unit/hooks/useInsightsFilters.test.ts @@ -1,7 +1,5 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; -import * as UseOnyxModule from '@hooks/useOnyx'; - import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import {buildInsightsQueryString} from '@pages/Insights/insightsQueries'; @@ -66,10 +64,6 @@ describe('useInsightsFilters', () => { await waitForBatchedUpdates(); }); - afterEach(() => { - jest.restoreAllMocks(); - }); - it('opens on the page defaults, in the default workspace currency', async () => { // Given a user whose default workspace reports in PLN, and no stored selections await setUpActivePolicy('PLN'); @@ -131,18 +125,22 @@ describe('useInsightsFilters', () => { }); it('waits for the stored selections before saying it has resolved', async () => { - // Given Onyx has not hydrated its cache yet - const useOnyxSpy = jest.spyOn(UseOnyxModule, 'default').mockReturnValue([undefined, {status: 'loading'}]); - // When the dashboard first asks for its filters - const {result, rerender} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + // Given stored selections that are still being written + Onyx.merge(ONYXKEYS.SEARCH_FILTERS, { + [SPEND_SEARCH_KEY]: { + query: buildInsightsQueryString({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: 'USD'}), + }, + }); + + // When the dashboard asks for its filters before that write lands + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); // Then it reports as unresolved, so the page doesn't request a dashboard the user never asked for expect(result.current.isResolved).toBe(false); - // When hydration finishes without stored selections, the defaults can safely be used - useOnyxSpy.mockReturnValue([undefined, {status: 'loaded'}]); - rerender({}); - expect(result.current.isResolved).toBe(true); + // And it resolves once the selections are in + await waitForBatchedUpdates(); + await waitFor(() => expect(result.current.isResolved).toBe(true)); }); it('stores a control’s selection alongside the rest of the dashboard’s filters', async () => { From 2627ee9458cdb6fa36ef9827a6559f48a5be535a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 14:16:01 +0100 Subject: [PATCH 13/15] chore(onyx): leave API migration to prerequisite bump --- src/setup/addUtilsToWindow.ts | 15 +++++++++++++++ src/types/modules/react-native-onyx.d.ts | 1 + 2 files changed, 16 insertions(+) diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts index e1147d5cd66b..ec3935f4e2b3 100644 --- a/src/setup/addUtilsToWindow.ts +++ b/src/setup/addUtilsToWindow.ts @@ -24,9 +24,24 @@ export default function addUtilsToWindow() { } window.Onyx = Onyx as typeof Onyx & { + get: (key: CollectionKeyBase) => Promise; log: (key: CollectionKeyBase) => void; }; + // We intentionally do not offer an Onyx.get API because we believe it will lead to code patterns we don't want to use in this repo, but we can offer a workaround for the sake of debugging + window.Onyx.get = function (key: CollectionKeyBase) { + return new Promise((resolve) => { + // We have opted for `connectWithoutView` here as this is a debugging utility and does not relate to any view. + const connection = Onyx.connectWithoutView({ + key, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + }; + window.Onyx.log = function (key: CollectionKeyBase) { window.Onyx.get(key).then((value) => { /* eslint-disable-next-line no-console */ diff --git a/src/types/modules/react-native-onyx.d.ts b/src/types/modules/react-native-onyx.d.ts index c091f53bce96..73615460afd9 100644 --- a/src/types/modules/react-native-onyx.d.ts +++ b/src/types/modules/react-native-onyx.d.ts @@ -16,6 +16,7 @@ declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { Onyx: typeof Onyx & { + get: (key: CollectionKeyBase) => Promise; log: (key: CollectionKeyBase) => void; }; } From 3740c03ec0453fd9293e5d0b544a9cb1880e07f7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 14:26:57 +0100 Subject: [PATCH 14/15] fix(onyx): preserve the public get API --- src/setup/addUtilsToWindow.ts | 15 --------------- src/types/modules/react-native-onyx.d.ts | 1 - 2 files changed, 16 deletions(-) diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts index ec3935f4e2b3..e1147d5cd66b 100644 --- a/src/setup/addUtilsToWindow.ts +++ b/src/setup/addUtilsToWindow.ts @@ -24,24 +24,9 @@ export default function addUtilsToWindow() { } window.Onyx = Onyx as typeof Onyx & { - get: (key: CollectionKeyBase) => Promise; log: (key: CollectionKeyBase) => void; }; - // We intentionally do not offer an Onyx.get API because we believe it will lead to code patterns we don't want to use in this repo, but we can offer a workaround for the sake of debugging - window.Onyx.get = function (key: CollectionKeyBase) { - return new Promise((resolve) => { - // We have opted for `connectWithoutView` here as this is a debugging utility and does not relate to any view. - const connection = Onyx.connectWithoutView({ - key, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - }; - window.Onyx.log = function (key: CollectionKeyBase) { window.Onyx.get(key).then((value) => { /* eslint-disable-next-line no-console */ diff --git a/src/types/modules/react-native-onyx.d.ts b/src/types/modules/react-native-onyx.d.ts index 73615460afd9..c091f53bce96 100644 --- a/src/types/modules/react-native-onyx.d.ts +++ b/src/types/modules/react-native-onyx.d.ts @@ -16,7 +16,6 @@ declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { Onyx: typeof Onyx & { - get: (key: CollectionKeyBase) => Promise; log: (key: CollectionKeyBase) => void; }; } From 0db1312b6191cb679ededbfae2f56559b6858ae8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 16:20:00 +0100 Subject: [PATCH 15/15] chore(ci): retrigger PR checks