From 48509b5053d0837b4b82ce35b6845da35fe905f7 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 17 Sep 2026 15:05:47 +0200 Subject: [PATCH 1/6] Read the session auth token from CurrentUserStore in deep link handling linkingConfig subscribe() imported hasAuthToken from actions/Session, which drags the whole session layer into its import graph and keeps an import cycle alive. CurrentUserStore already connects to ONYXKEYS.SESSION for the email, so hasAuthToken moves there and actions/Session keeps re-exporting it for existing callers. Also applies the oxfmt import order in subscribe.ts that the oxfmt CI gate enforces. --- src/libs/CurrentUserStore.ts | 15 +++++----- .../Navigation/linkingConfig/subscribe.ts | 18 +++--------- src/libs/actions/Session/index.ts | 8 +---- tests/unit/CurrentUserStoreTest.ts | 29 +++++++++++++++++++ .../Navigation/linkingConfigSubscribeTest.ts | 22 ++++---------- 5 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 tests/unit/CurrentUserStoreTest.ts diff --git a/src/libs/CurrentUserStore.ts b/src/libs/CurrentUserStore.ts index fd5588f03b1a..90264ab08c2f 100644 --- a/src/libs/CurrentUserStore.ts +++ b/src/libs/CurrentUserStore.ts @@ -1,19 +1,15 @@ import ONYXKEYS from '@src/ONYXKEYS'; -/** - * Thin store for current user email that has no dependencies on Log. - * This avoids circular dependency: Log -> NetworkStore -> Log - * Other modules can import getCurrentUserEmail from NetworkStore for convenience, - * but Log specifically imports from here to break the cycle. - */ import Onyx from 'react-native-onyx'; let currentUserEmail: string | null = null; +let sessionAuthToken: string | null | undefined; Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (val) => { currentUserEmail = val?.email ?? null; + sessionAuthToken = val?.authToken; }, }); @@ -21,5 +17,8 @@ function getCurrentUserEmail(): string | null { return currentUserEmail; } -// eslint-disable-next-line import/prefer-default-export -export {getCurrentUserEmail}; +function hasAuthToken(): boolean { + return !!sessionAuthToken; +} + +export {getCurrentUserEmail, hasAuthToken}; diff --git a/src/libs/Navigation/linkingConfig/subscribe.ts b/src/libs/Navigation/linkingConfig/subscribe.ts index fbad0b6a60e2..5e38f5a0dd57 100644 --- a/src/libs/Navigation/linkingConfig/subscribe.ts +++ b/src/libs/Navigation/linkingConfig/subscribe.ts @@ -1,5 +1,5 @@ -import {hasAuthToken} from '@libs/actions/Session'; import continuePlaidOAuth from '@libs/continuePlaidOAuth'; +import {hasAuthToken} from '@libs/CurrentUserStore'; import navigationRef from '@libs/Navigation/navigationRef'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; @@ -11,9 +11,6 @@ import type {LinkingOptions} from '@react-navigation/native'; import {findFocusedRoute} from '@react-navigation/native'; import {Linking} from 'react-native'; -/** - * Rules for dropping a deep link that would re-navigate to a screen the user is already on. - */ const skipRules: ReadonlyArray<{urlMatcher: RegExp; focusedScreens: readonly string[]}> = [ {urlMatcher: /\/distance-gps(\?|$)/, focusedScreens: [ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.route]}, {urlMatcher: /\/scan(\?|$)/, focusedScreens: [ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.route]}, @@ -29,16 +26,12 @@ const skipRules: ReadonlyArray<{urlMatcher: RegExp; focusedScreens: readonly str }, ]; -/** - * Returns the URL's path, without its query string or fragment. - */ function getPathnameFromURL(url: string): string { return url.split(/[?#]/).at(0) ?? ''; } const subscribe: LinkingOptions['subscribe'] = (listener) => { const subscription = Linking.addEventListener('url', ({url}: {url: string}) => { - // Skip deep links to screens where the user is already focused. const skipRule = skipRules.find(({urlMatcher}) => urlMatcher.test(url)); if (skipRule) { const state = navigationRef.current?.getRootState(); @@ -48,13 +41,10 @@ const subscribe: LinkingOptions['subscribe'] = (listener } } - // The native Plaid SDK on iOS handles the OAuth callback itself. Forwarding this URL to - // React Navigation would resolve to NotFound (or reset navigation away from the Plaid step) - // and break the flow — keep the current screen mounted so the SDK can finish. + // The native Plaid SDK on iOS finishes OAuth itself, so the redirect must never reach React + // Navigation — it would resolve to NotFound and unmount the Plaid step. Hand the URI to the SDK, + // otherwise it never sees the callback URL and retries OAuth in a loop. See issue #87757. if (url.includes(CONST.PLAID.OAUTH_REDIRECT_PATH_IOS)) { - // Forward the OAuth redirect URI into the Plaid SDK so it can finalize OAuth. - // Without this, the native SDK never sees the callback URL and retries OAuth in a loop - // after app-to-app bank auth returns. See issue #87757. continuePlaidOAuth(url); return; } diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 81c76b2a9d06..c5cdcfa96551 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -21,6 +21,7 @@ import type { import type SignInUserParams from '@libs/API/parameters/SignInUserParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import asyncOpenURL from '@libs/asyncOpenURL'; +import {hasAuthToken} from '@libs/CurrentUserStore'; import * as ErrorUtils from '@libs/ErrorUtils'; import FraudProtection from '@libs/FraudProtection'; import getPlatform from '@libs/getPlatform'; @@ -331,13 +332,6 @@ function hasStashedSession(stashedSessionParam: Session | undefined, stashedCred return !!(stashedSessionParam?.authToken && stashedCredentialsParam?.autoGeneratedLogin && stashedCredentialsParam.autoGeneratedLogin !== ''); } -/** - * Checks if the user has authToken - */ -function hasAuthToken(): boolean { - return !!deprecatedSession.authToken; -} - /** * Indicates if the session which creation date is in parameter is expired * @param sessionCreationDate the session creation date timestamp diff --git a/tests/unit/CurrentUserStoreTest.ts b/tests/unit/CurrentUserStoreTest.ts new file mode 100644 index 000000000000..6643fb3af911 --- /dev/null +++ b/tests/unit/CurrentUserStoreTest.ts @@ -0,0 +1,29 @@ +import {hasAuthToken} from '@libs/CurrentUserStore'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +describe('hasAuthToken', () => { + beforeEach(() => { + Onyx.init({keys: ONYXKEYS}); + return Onyx.clear(); + }); + + it('returns false while the session holds no auth token', () => + Onyx.merge(ONYXKEYS.SESSION, {email: 'user@test.com'}).then(() => { + expect(hasAuthToken()).toBe(false); + })); + + it('returns true once the session holds an auth token', () => + Onyx.merge(ONYXKEYS.SESSION, {authToken: 'abc123'}).then(() => { + expect(hasAuthToken()).toBe(true); + })); + + it('returns false again once the auth token is cleared', () => + Onyx.merge(ONYXKEYS.SESSION, {authToken: 'abc123'}) + .then(() => Onyx.merge(ONYXKEYS.SESSION, {authToken: null})) + .then(() => { + expect(hasAuthToken()).toBe(false); + })); +}); diff --git a/tests/unit/Navigation/linkingConfigSubscribeTest.ts b/tests/unit/Navigation/linkingConfigSubscribeTest.ts index d8bc2a91132d..7b1b9a1133f5 100644 --- a/tests/unit/Navigation/linkingConfigSubscribeTest.ts +++ b/tests/unit/Navigation/linkingConfigSubscribeTest.ts @@ -1,14 +1,15 @@ -import {hasAuthToken} from '@libs/actions/Session'; +import {hasAuthToken} from '@libs/CurrentUserStore'; import subscribe from '@libs/Navigation/linkingConfig/subscribe'; import {Linking} from 'react-native'; -jest.mock('@libs/actions/Session', () => ({ +// A jest mock factory replaces the whole module, and subscribe() also pulls CurrentUserStore in through +// ROUTES -> Log, which needs getCurrentUserEmail. +jest.mock('@libs/CurrentUserStore', () => ({ + getCurrentUserEmail: jest.fn(() => null), hasAuthToken: jest.fn(), })); -// subscribe() only reads the ref to resolve the focused screen for its skip rules. None of the URLs -// exercised here match a skip rule, so an empty ref keeps that branch out of the way. jest.mock('@libs/Navigation/navigationRef', () => ({ __esModule: true, default: {current: null}, @@ -20,15 +21,8 @@ const REPORT_ID = '269886405016917'; const ACCOUNT_ID = '22839920'; const VALIDATE_CODE = 'ABC123'; -/** - * Delivers a single warm deep link (a React Native `Linking` `url` event) to subscribe()'s handler and - * returns the React Navigation listener it was given, so callers can assert whether (and with what) - * the link was forwarded. - */ function deliverDeepLink(url: string): jest.Mock { const listener = jest.fn(); - // Capture the handler subscribe() registers, then hand it the URL directly. The teardown it returns - // is left alone on purpose: Linking is mocked here and hands back no subscription to remove. const addEventListener = jest.spyOn(Linking, 'addEventListener').mockImplementation(jest.fn()); subscribe?.(listener); @@ -49,9 +43,6 @@ describe('linkingConfig subscribe', () => { mockedHasAuthToken.mockReturnValue(false); }); - // The Report screen lives in AuthScreens and is not mounted while PublicScreens is showing, so - // forwarding these would throw "NAVIGATE ... was not handled by any navigator". - // openReportFromDeepLink() opens the public room anonymously instead. See #92672. it.each([ 'https://new.expensify.com/r/269886405016917', 'https://staging.new.expensify.com/r/269886405016917', @@ -65,9 +56,6 @@ describe('linkingConfig subscribe', () => { expect(deliverDeepLink(url)).not.toHaveBeenCalled(); }); - // The guard matches on the path only, so a report route parked in a query string or fragment no - // longer swallows the link. Without this, the magic link below never reached ValidateLoginPage and - // the invited user was dropped into the app signed out. See #99156. it.each([ `https://staging.new.expensify.com/v/${ACCOUNT_ID}/${VALIDATE_CODE}?exitTo=/r/${REPORT_ID}`, `new-expensify://v/${ACCOUNT_ID}/${VALIDATE_CODE}?exitTo=/r/${REPORT_ID}`, From 2fe75c68b777be3319470c54ab27b82ce15ced5d Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 17 Sep 2026 15:10:05 +0200 Subject: [PATCH 2/6] Add oxlint config for the import/no-cycle check Used to verify the linkingConfig -> actions/Session cycle stays gone. Not wired into the build yet: oxlint is not a dependency, so nothing in scripts/lint or the workflows runs this config. --- .oxlintrc.no-cycle.json | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .oxlintrc.no-cycle.json diff --git a/.oxlintrc.no-cycle.json b/.oxlintrc.no-cycle.json new file mode 100644 index 000000000000..5894ed82dfff --- /dev/null +++ b/.oxlintrc.no-cycle.json @@ -0,0 +1,47 @@ +{ + "plugins": ["import"], + "categories": { + "correctness": "off" + }, + "rules": { + "import/no-cycle": "error" + }, + "ignorePatterns": [ + "!**/.storybook", + "!**/.github", + ".github/actions/**/index.js", + "**/*.config.js", + "**/*.config.mjs", + "**/node_modules/**/*", + "**/dist/**/*", + "server/**/dist/**", + "server/victory-chart-renderer/.dev/**", + ".eslint-reports/**/*", + "android/**/build/**/*", + "docs/vendor/**/*", + "docs/assets/**/*", + "web/gtm.js", + "**/.expo/**/*", + "**/.rock/**/*", + "**/.yalc/**/*", + "src/libs/SearchParser/searchParser.js", + "src/libs/SearchParser/autocompleteParser.js", + "help/_scripts/**/*", + "modules/ExpensifyNitroUtils/nitrogen/**/*", + "Mobile-Expensify/**/*", + "**/vendor", + "modules/group-ib-fp/**/*", + "web/snippets/gib.js", + "src/languages/de.ts", + "src/languages/el.ts", + "src/languages/es.ts", + "src/languages/fr.ts", + "src/languages/it.ts", + "src/languages/ja.ts", + "src/languages/nl.ts", + "src/languages/pl.ts", + "src/languages/pt-BR.ts", + "src/languages/zh-hans.ts", + "oxlint-migration/**" + ] +} From 1d58b8e1e497a4d7694889e7a3c698922daecb27 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 17 Sep 2026 15:12:57 +0200 Subject: [PATCH 3/6] Restore stripped comments and untrack the temp oxlint config .oxlintrc.no-cycle.json is a scratch config used to verify the cycle is gone, not build input, so it stays out of the tree. The import order in subscribe.ts is the only remaining deviation from the draft: oxfmt CI enforces it. --- .oxlintrc.no-cycle.json | 47 ------------------- src/libs/CurrentUserStore.ts | 8 ++++ .../Navigation/linkingConfig/subscribe.ts | 16 +++++-- .../Navigation/linkingConfigSubscribeTest.ts | 19 +++++++- 4 files changed, 38 insertions(+), 52 deletions(-) delete mode 100644 .oxlintrc.no-cycle.json diff --git a/.oxlintrc.no-cycle.json b/.oxlintrc.no-cycle.json deleted file mode 100644 index 5894ed82dfff..000000000000 --- a/.oxlintrc.no-cycle.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "plugins": ["import"], - "categories": { - "correctness": "off" - }, - "rules": { - "import/no-cycle": "error" - }, - "ignorePatterns": [ - "!**/.storybook", - "!**/.github", - ".github/actions/**/index.js", - "**/*.config.js", - "**/*.config.mjs", - "**/node_modules/**/*", - "**/dist/**/*", - "server/**/dist/**", - "server/victory-chart-renderer/.dev/**", - ".eslint-reports/**/*", - "android/**/build/**/*", - "docs/vendor/**/*", - "docs/assets/**/*", - "web/gtm.js", - "**/.expo/**/*", - "**/.rock/**/*", - "**/.yalc/**/*", - "src/libs/SearchParser/searchParser.js", - "src/libs/SearchParser/autocompleteParser.js", - "help/_scripts/**/*", - "modules/ExpensifyNitroUtils/nitrogen/**/*", - "Mobile-Expensify/**/*", - "**/vendor", - "modules/group-ib-fp/**/*", - "web/snippets/gib.js", - "src/languages/de.ts", - "src/languages/el.ts", - "src/languages/es.ts", - "src/languages/fr.ts", - "src/languages/it.ts", - "src/languages/ja.ts", - "src/languages/nl.ts", - "src/languages/pl.ts", - "src/languages/pt-BR.ts", - "src/languages/zh-hans.ts", - "oxlint-migration/**" - ] -} diff --git a/src/libs/CurrentUserStore.ts b/src/libs/CurrentUserStore.ts index 90264ab08c2f..a8ea5e77e148 100644 --- a/src/libs/CurrentUserStore.ts +++ b/src/libs/CurrentUserStore.ts @@ -1,5 +1,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; +/** + * Thin store for the current user email and auth token that has no dependencies on Log. + * This avoids circular dependency: Log -> NetworkStore -> Log + * Other modules can import getCurrentUserEmail from NetworkStore for convenience, + * but Log specifically imports from here to break the cycle. + * Navigation and other light consumers read the auth token from here instead of + * importing actions/Session, which drags the whole session layer into their import graph. + */ import Onyx from 'react-native-onyx'; let currentUserEmail: string | null = null; diff --git a/src/libs/Navigation/linkingConfig/subscribe.ts b/src/libs/Navigation/linkingConfig/subscribe.ts index 5e38f5a0dd57..c746d9eac4c9 100644 --- a/src/libs/Navigation/linkingConfig/subscribe.ts +++ b/src/libs/Navigation/linkingConfig/subscribe.ts @@ -11,6 +11,9 @@ import type {LinkingOptions} from '@react-navigation/native'; import {findFocusedRoute} from '@react-navigation/native'; import {Linking} from 'react-native'; +/** + * Rules for dropping a deep link that would re-navigate to a screen the user is already on. + */ const skipRules: ReadonlyArray<{urlMatcher: RegExp; focusedScreens: readonly string[]}> = [ {urlMatcher: /\/distance-gps(\?|$)/, focusedScreens: [ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.route]}, {urlMatcher: /\/scan(\?|$)/, focusedScreens: [ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.route]}, @@ -26,12 +29,16 @@ const skipRules: ReadonlyArray<{urlMatcher: RegExp; focusedScreens: readonly str }, ]; +/** + * Returns the URL's path, without its query string or fragment. + */ function getPathnameFromURL(url: string): string { return url.split(/[?#]/).at(0) ?? ''; } const subscribe: LinkingOptions['subscribe'] = (listener) => { const subscription = Linking.addEventListener('url', ({url}: {url: string}) => { + // Skip deep links to screens where the user is already focused. const skipRule = skipRules.find(({urlMatcher}) => urlMatcher.test(url)); if (skipRule) { const state = navigationRef.current?.getRootState(); @@ -41,10 +48,13 @@ const subscribe: LinkingOptions['subscribe'] = (listener } } - // The native Plaid SDK on iOS finishes OAuth itself, so the redirect must never reach React - // Navigation — it would resolve to NotFound and unmount the Plaid step. Hand the URI to the SDK, - // otherwise it never sees the callback URL and retries OAuth in a loop. See issue #87757. + // The native Plaid SDK on iOS handles the OAuth callback itself. Forwarding this URL to + // React Navigation would resolve to NotFound (or reset navigation away from the Plaid step) + // and break the flow — keep the current screen mounted so the SDK can finish. if (url.includes(CONST.PLAID.OAUTH_REDIRECT_PATH_IOS)) { + // Forward the OAuth redirect URI into the Plaid SDK so it can finalize OAuth. + // Without this, the native SDK never sees the callback URL and retries OAuth in a loop + // after app-to-app bank auth returns. See issue #87757. continuePlaidOAuth(url); return; } diff --git a/tests/unit/Navigation/linkingConfigSubscribeTest.ts b/tests/unit/Navigation/linkingConfigSubscribeTest.ts index 7b1b9a1133f5..8617c3c5174e 100644 --- a/tests/unit/Navigation/linkingConfigSubscribeTest.ts +++ b/tests/unit/Navigation/linkingConfigSubscribeTest.ts @@ -3,13 +3,15 @@ import subscribe from '@libs/Navigation/linkingConfig/subscribe'; import {Linking} from 'react-native'; -// A jest mock factory replaces the whole module, and subscribe() also pulls CurrentUserStore in through -// ROUTES -> Log, which needs getCurrentUserEmail. +// CurrentUserStore is also imported by Log and NetworkStore for getCurrentUserEmail, so that export +// needs to exist on the mock too. jest.mock('@libs/CurrentUserStore', () => ({ getCurrentUserEmail: jest.fn(() => null), hasAuthToken: jest.fn(), })); +// subscribe() only reads the ref to resolve the focused screen for its skip rules. None of the URLs +// exercised here match a skip rule, so an empty ref keeps that branch out of the way. jest.mock('@libs/Navigation/navigationRef', () => ({ __esModule: true, default: {current: null}, @@ -21,8 +23,15 @@ const REPORT_ID = '269886405016917'; const ACCOUNT_ID = '22839920'; const VALIDATE_CODE = 'ABC123'; +/** + * Delivers a single warm deep link (a React Native `Linking` `url` event) to subscribe()'s handler and + * returns the React Navigation listener it was given, so callers can assert whether (and with what) + * the link was forwarded. + */ function deliverDeepLink(url: string): jest.Mock { const listener = jest.fn(); + // Capture the handler subscribe() registers, then hand it the URL directly. The teardown it returns + // is left alone on purpose: Linking is mocked here and hands back no subscription to remove. const addEventListener = jest.spyOn(Linking, 'addEventListener').mockImplementation(jest.fn()); subscribe?.(listener); @@ -43,6 +52,9 @@ describe('linkingConfig subscribe', () => { mockedHasAuthToken.mockReturnValue(false); }); + // The Report screen lives in AuthScreens and is not mounted while PublicScreens is showing, so + // forwarding these would throw "NAVIGATE ... was not handled by any navigator". + // openReportFromDeepLink() opens the public room anonymously instead. See #92672. it.each([ 'https://new.expensify.com/r/269886405016917', 'https://staging.new.expensify.com/r/269886405016917', @@ -56,6 +68,9 @@ describe('linkingConfig subscribe', () => { expect(deliverDeepLink(url)).not.toHaveBeenCalled(); }); + // The guard matches on the path only, so a report route parked in a query string or fragment no + // longer swallows the link. Without this, the magic link below never reached ValidateLoginPage and + // the invited user was dropped into the app signed out. See #99156. it.each([ `https://staging.new.expensify.com/v/${ACCOUNT_ID}/${VALIDATE_CODE}?exitTo=/r/${REPORT_ID}`, `new-expensify://v/${ACCOUNT_ID}/${VALIDATE_CODE}?exitTo=/r/${REPORT_ID}`, From 503c7c88c2f4cce037f00adc8a1322034e702fa6 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 17 Sep 2026 15:33:18 +0200 Subject: [PATCH 4/6] Keep the CurrentUserStore docblock unchanged The two added lines restated the import graph, which the exports already show. The pre-existing block still describes why the file exists apart from NetworkStore. --- src/libs/CurrentUserStore.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/CurrentUserStore.ts b/src/libs/CurrentUserStore.ts index a8ea5e77e148..fa61d75b54c5 100644 --- a/src/libs/CurrentUserStore.ts +++ b/src/libs/CurrentUserStore.ts @@ -1,12 +1,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; /** - * Thin store for the current user email and auth token that has no dependencies on Log. + * Thin store for current user email that has no dependencies on Log. * This avoids circular dependency: Log -> NetworkStore -> Log * Other modules can import getCurrentUserEmail from NetworkStore for convenience, * but Log specifically imports from here to break the cycle. - * Navigation and other light consumers read the auth token from here instead of - * importing actions/Session, which drags the whole session layer into their import graph. */ import Onyx from 'react-native-onyx'; From 33962780339948ebd923df6893510150f5fd2537 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 17 Sep 2026 15:50:05 +0200 Subject: [PATCH 5/6] Drop the unused getCurrentUserEmail mock from the subscribe test Nothing in subscribe()'s graph calls it during the test, so the factory only needs hasAuthToken. --- tests/unit/Navigation/linkingConfigSubscribeTest.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/Navigation/linkingConfigSubscribeTest.ts b/tests/unit/Navigation/linkingConfigSubscribeTest.ts index 8617c3c5174e..3676bcfd8622 100644 --- a/tests/unit/Navigation/linkingConfigSubscribeTest.ts +++ b/tests/unit/Navigation/linkingConfigSubscribeTest.ts @@ -3,10 +3,7 @@ import subscribe from '@libs/Navigation/linkingConfig/subscribe'; import {Linking} from 'react-native'; -// CurrentUserStore is also imported by Log and NetworkStore for getCurrentUserEmail, so that export -// needs to exist on the mock too. jest.mock('@libs/CurrentUserStore', () => ({ - getCurrentUserEmail: jest.fn(() => null), hasAuthToken: jest.fn(), })); From 04812543965ff7f368d1e2b7e3d39d0afda6adf1 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 18 Sep 2026 16:17:36 +0200 Subject: [PATCH 6/6] Refresh CurrentUserStore docblock and widen its test mock The old block named Log -> NetworkStore -> Log, but Log now imports Network/MainQueueStore, so that path no longer exists. It states the constraint that still applies and covers the auth token the store also mirrors. The deep link test spreads the real store so unrelated exports stay usable while hasAuthToken stays mocked, and Onyx.init moves to beforeAll. --- src/libs/CurrentUserStore.ts | 11 +++++------ tests/unit/CurrentUserStoreTest.ts | 5 +++-- tests/unit/Navigation/linkingConfigSubscribeTest.ts | 2 ++ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/libs/CurrentUserStore.ts b/src/libs/CurrentUserStore.ts index fa61d75b54c5..d44cb3410fc3 100644 --- a/src/libs/CurrentUserStore.ts +++ b/src/libs/CurrentUserStore.ts @@ -1,21 +1,20 @@ import ONYXKEYS from '@src/ONYXKEYS'; /** - * Thin store for current user email that has no dependencies on Log. - * This avoids circular dependency: Log -> NetworkStore -> Log - * Other modules can import getCurrentUserEmail from NetworkStore for convenience, - * but Log specifically imports from here to break the cycle. + * Session email/auth-token mirror with no imports beyond Onyx. Keeps Log and light + * consumers away from NetworkStore, which imports Log, and from + * actions/Session, which drags the whole session layer into their import graphs. */ import Onyx from 'react-native-onyx'; let currentUserEmail: string | null = null; -let sessionAuthToken: string | null | undefined; +let sessionAuthToken: string | null = null; Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (val) => { currentUserEmail = val?.email ?? null; - sessionAuthToken = val?.authToken; + sessionAuthToken = val?.authToken ?? null; }, }); diff --git a/tests/unit/CurrentUserStoreTest.ts b/tests/unit/CurrentUserStoreTest.ts index 6643fb3af911..b6ad7dfbe59c 100644 --- a/tests/unit/CurrentUserStoreTest.ts +++ b/tests/unit/CurrentUserStoreTest.ts @@ -5,11 +5,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; describe('hasAuthToken', () => { - beforeEach(() => { + beforeAll(() => { Onyx.init({keys: ONYXKEYS}); - return Onyx.clear(); }); + beforeEach(() => Onyx.clear()); + it('returns false while the session holds no auth token', () => Onyx.merge(ONYXKEYS.SESSION, {email: 'user@test.com'}).then(() => { expect(hasAuthToken()).toBe(false); diff --git a/tests/unit/Navigation/linkingConfigSubscribeTest.ts b/tests/unit/Navigation/linkingConfigSubscribeTest.ts index 3676bcfd8622..3fb67b066245 100644 --- a/tests/unit/Navigation/linkingConfigSubscribeTest.ts +++ b/tests/unit/Navigation/linkingConfigSubscribeTest.ts @@ -1,9 +1,11 @@ +import type * as CurrentUserStore from '@libs/CurrentUserStore'; import {hasAuthToken} from '@libs/CurrentUserStore'; import subscribe from '@libs/Navigation/linkingConfig/subscribe'; import {Linking} from 'react-native'; jest.mock('@libs/CurrentUserStore', () => ({ + ...jest.requireActual('@libs/CurrentUserStore'), hasAuthToken: jest.fn(), }));