From 3cfba66db66e7f4758637b8d1be6eec17074575b Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 12:11:08 -0300 Subject: [PATCH 01/12] fix: give every saga exit a terminal UI root restore() and handleShareExtension() each had an early exit that pushed no root-changing action. APP.START is the only thing that hides the boot splash and the only thing that moves the root off a loading value, so those exits stranded the app on a loading root with no recovery. The un-raced take(LOGIN.SUCCESS) in handleShareExtension had the same effect from a far more likely cause: SERVER.SELECT_FAILURE is handled only by the server reducer and never touches app.root, so a failed connect left the take waiting forever. It now races the two failure actions that selectServer and login actually emit. No timeout is added to the race. selectServer's catch always emits selectServerFailure, so the failure modes are covered by action, and a bare timer here would re-introduce the regression recorded at login.js:490. --- app/sagas/__tests__/deepLinking.test.ts | 79 +++++++++++++++- app/sagas/__tests__/init.test.ts | 117 ++++++++++++++++++++++++ app/sagas/deepLinking.js | 13 ++- app/sagas/init.js | 5 +- 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 app/sagas/__tests__/init.test.ts diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index e6ceccde958..8d77add7b5c 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -96,8 +96,8 @@ import { applyMiddleware, createStore } from 'redux'; import createSagaMiddleware from 'redux-saga'; import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLinking'; -import { loginSuccess } from '../../actions/login'; -import { selectServerSuccess } from '../../actions/server'; +import { loginFailure, loginSuccess } from '../../actions/login'; +import { selectServerFailure, selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; @@ -585,3 +585,78 @@ describe('deepLinking saga — handleOAuth dedup guard', () => { }); }); }); + +// ─── handleShareExtension — every exit must land on a terminal root ────────── + +describe('deepLinking saga — handleShareExtension terminal roots', () => { + beforeEach(() => { + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === 'currentServer') return HOST; + return makeStoredUser(); + }); + sdk.current.client.host = ''; + }); + + afterEach(() => { + sdk.current.client.host = ''; + }); + + it('leaves ROOT_OUTSIDE, not the loading root, when the server record is missing', async () => { + jest.mocked(getServerById).mockResolvedValue(null); + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('leaves ROOT_OUTSIDE when the login that the share sheet waits on fails', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + expect(store.getState().app.root).toBe(RootEnum.ROOT_LOADING_SHARE_EXTENSION); + + store.dispatch(loginFailure({ message: 'connect failed' })); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('leaves ROOT_OUTSIDE when selecting the server fails while the share sheet waits', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + store.dispatch(selectServerFailure()); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('still reaches ROOT_SHARE_EXTENSION when the login succeeds', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: TOKEN } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); +}); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts new file mode 100644 index 00000000000..c8aeded1165 --- /dev/null +++ b/app/sagas/__tests__/init.test.ts @@ -0,0 +1,117 @@ +// ─── Boundary mocks — must appear before any import that triggers the module ─── + +jest.mock('../../lib/methods/userPreferences', () => ({ + __esModule: true, + default: { + getString: jest.fn() + } +})); + +jest.mock('../../lib/database/services/Server', () => ({ + getServerById: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + localAuthenticate: jest.fn() +})); + +jest.mock('../../lib/methods/userPreferencesMethods', () => ({ + getSortPreferences: jest.fn(() => ({})) +})); + +jest.mock('../../actions/deepLinking', () => ({ + deepLinkingClickCallPush: jest.fn() +})); + +jest.mock('react-native-bootsplash', () => ({ + __esModule: true, + default: { hide: jest.fn(() => Promise.resolve()) } +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve(null)) + } +})); + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn() + } + } +})); + +// ─── Real imports (after mocks) ─────────────────────────────────────────────── + +import { applyMiddleware, createStore } from 'redux'; +import createSagaMiddleware from 'redux-saga'; +import RNBootSplash from 'react-native-bootsplash'; + +import { appInit } from '../../actions/app'; +import { RootEnum } from '../../definitions'; +import reducers from '../../reducers'; +import initRoot from '../init'; +import UserPreferences from '../../lib/methods/userPreferences'; +import { getServerById } from '../../lib/database/services/Server'; + +/** Drains pending saga microtasks so all synchronous saga steps complete. */ +async function flushSagaMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function setupStore() { + const sagaMiddleware = createSagaMiddleware(); + const store = createStore(reducers, undefined, applyMiddleware(sagaMiddleware)); + sagaMiddleware.run(initRoot); + return store; +} + +const HOST = 'https://open.rocket.chat'; + +describe('init saga — restore terminal roots', () => { + beforeEach(() => { + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(RNBootSplash.hide).mockClear(); + jest.mocked(UserPreferences.getString).mockImplementation(() => HOST); + }); + + it('lands on ROOT_OUTSIDE and hides the splash when the stored server has no database record', async () => { + jest.mocked(getServerById).mockResolvedValue(null); + const store = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(jest.mocked(RNBootSplash.hide)).toHaveBeenCalled(); + }); + + it('marks the app ready when the stored server has no database record', async () => { + jest.mocked(getServerById).mockResolvedValue(null); + const store = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.ready).toBe(true); + }); + + it('selects the stored server and marks the app ready when the record exists', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + const store = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.ready).toBe(true); + expect(store.getState().server.server).toBe(HOST); + }); +}); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index fac9e3292db..80c5d1edfa6 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -1,7 +1,7 @@ import { InteractionManager } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; import I18n from 'i18n-js'; -import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects'; +import { all, call, delay, put, race, select, take, takeLatest } from 'redux-saga/effects'; import { shareSetParams } from '../actions/share'; import * as types from '../actions/actionsTypes'; @@ -156,11 +156,20 @@ const handleShareExtension = function* handleOpen({ params }) { yield localAuthenticate(server); const serverRecord = yield getServerById(server); if (!serverRecord) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); return; } yield put(selectServerRequest(server, serverRecord.version)); if (sdk.current?.client?.host !== server) { - yield take(types.LOGIN.SUCCESS); + const { loginSuccess } = yield race({ + loginSuccess: take(types.LOGIN.SUCCESS), + loginFailure: take(types.LOGIN.FAILURE), + selectServerFailure: take(types.SERVER.SELECT_FAILURE) + }); + if (!loginSuccess) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } } yield put(shareSetParams(params)); yield put(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); diff --git a/app/sagas/init.js b/app/sagas/init.js index d9d6024abe8..3a5a1cc3243 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -48,9 +48,10 @@ const restore = function* restore() { yield localAuthenticate(server); const serverRecord = yield getServerById(server); if (!serverRecord) { - return; + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + } else { + yield put(selectServerRequest(server, serverRecord.version)); } - yield put(selectServerRequest(server, serverRecord.version)); } yield put(appReady({})); From 7a19445ceee97f4ed56c066b9f4c210fd030aa17 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 12:21:06 -0300 Subject: [PATCH 02/12] test: name the saga exit assertions after user-facing roots --- app/sagas/__tests__/deepLinking.test.ts | 4 ++-- app/sagas/__tests__/init.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 3088b15fd8f..eb8625e11d1 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -602,9 +602,9 @@ describe('deepLinking saga — unknown host hands off to the add-server flow', ( }); }); -// ─── handleShareExtension — every exit must land on a terminal root ────────── +// ─── handleShareExtension — every exit must land on a user-facing root ────────── -describe('deepLinking saga — handleShareExtension terminal roots', () => { +describe('deepLinking saga — handleShareExtension user-facing roots', () => { beforeEach(() => { jest.mocked(UserPreferences.getString).mockReset(); jest.mocked(getServerById).mockReset(); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 073afac1368..78c273115f3 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -61,7 +61,7 @@ const setupStore = (): RecordingStore => createRecordingStore(initRoot); const HOST = 'https://open.rocket.chat'; -describe('init saga — restore terminal roots', () => { +describe('init saga — restore user-facing roots', () => { beforeEach(() => { jest.mocked(UserPreferences.getString).mockReset(); jest.mocked(getServerById).mockReset(); From 81710498e10e18b2b247cd6d2d016053aea84c0b Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 13:45:07 -0300 Subject: [PATCH 03/12] fix: close the remaining saga exits that skip a user-facing root restore()'s other-logged-in-server branch passed the server id where a record was expected, so selectServerRequest always received an undefined version, and its return skipped appReady and the pending push handling. handleShareExtension's race missed LOGOUT, which login.js emits instead of LOGIN.FAILURE for logged-out-by-server, expired-token, and 401-with-user, and its body was unguarded, so a throw from localAuthenticate or getServerById left the share sheet on the loading root. --- app/sagas/__tests__/deepLinking.test.ts | 27 +++++++++++++++++- app/sagas/__tests__/init.test.ts | 25 ++++++++++++++++ app/sagas/deepLinking.js | 38 ++++++++++++++----------- app/sagas/init.js | 7 +++-- 4 files changed, 78 insertions(+), 19 deletions(-) diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index eb8625e11d1..cb0acf8c4c6 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -96,11 +96,12 @@ import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLin import { loginFailure, loginSuccess } from '../../actions/login'; import { selectServerFailure, selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; -import { APP, SERVER } from '../../actions/actionsTypes'; +import { APP, LOGOUT, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; +import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom'; @@ -657,6 +658,30 @@ describe('deepLinking saga — handleShareExtension user-facing roots', () => { expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); }); + it('leaves ROOT_OUTSIDE when the server logs the share sheet out instead of failing the login', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + store.dispatch({ type: LOGOUT }); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('leaves ROOT_OUTSIDE when local authentication throws', async () => { + jest.mocked(localAuthenticate).mockRejectedValueOnce(new Error('biometrics unavailable')); + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + it('still reaches ROOT_SHARE_EXTENSION when the login succeeds', async () => { jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); const { store } = setupStore(); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 78c273115f3..cfa856b5629 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -54,12 +54,14 @@ import { RootEnum } from '../../definitions'; import initRoot from '../init'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; +import database from '../../lib/database'; import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; import type { RecordingStore } from '../../lib/testUtils/sagaStore'; const setupStore = (): RecordingStore => createRecordingStore(initRoot); const HOST = 'https://open.rocket.chat'; +const OTHER_HOST = 'https://other.rocket.chat'; describe('init saga — restore user-facing roots', () => { beforeEach(() => { @@ -94,6 +96,29 @@ describe('init saga — restore user-facing roots', () => { expect(store.getState().app.ready).toBe(true); }); + it('selects another logged in server with its own version when the stored server has no token', async () => { + jest + .mocked(UserPreferences.getString) + .mockImplementation(key => + key === `reactnativemeteor_usertoken-${OTHER_HOST}` + ? 'token' + : key.startsWith('reactnativemeteor_usertoken-') + ? null + : HOST + ); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: () => Promise.resolve([{ id: OTHER_HOST, version: '7.0.0' }]) }) + } as any); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().server.server).toBe(OTHER_HOST); + expect(store.getState().server.version).toBe('7.0.0'); + expect(store.getState().app.ready).toBe(true); + }); + it('selects the stored server and marks the app ready when the record exists', async () => { jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); const { store } = setupStore(); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 80c5d1edfa6..680e6b5e80b 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -153,26 +153,32 @@ const handleShareExtension = function* handleOpen({ params }) { } yield put(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); - yield localAuthenticate(server); - const serverRecord = yield getServerById(server); - if (!serverRecord) { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - return; - } - yield put(selectServerRequest(server, serverRecord.version)); - if (sdk.current?.client?.host !== server) { - const { loginSuccess } = yield race({ - loginSuccess: take(types.LOGIN.SUCCESS), - loginFailure: take(types.LOGIN.FAILURE), - selectServerFailure: take(types.SERVER.SELECT_FAILURE) - }); - if (!loginSuccess) { + try { + yield localAuthenticate(server); + const serverRecord = yield getServerById(server); + if (!serverRecord) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); return; } + yield put(selectServerRequest(server, serverRecord.version)); + if (sdk.current?.client?.host !== server) { + const { loginSuccess } = yield race({ + loginSuccess: take(types.LOGIN.SUCCESS), + loginFailure: take(types.LOGIN.FAILURE), + selectServerFailure: take(types.SERVER.SELECT_FAILURE), + logout: take(types.LOGOUT) + }); + if (!loginSuccess) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } + } + yield put(shareSetParams(params)); + yield put(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); + } catch (e) { + log(e); + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } - yield put(shareSetParams(params)); - yield put(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); }; const handleOpen = function* handleOpen({ params }) { diff --git a/app/sagas/init.js b/app/sagas/init.js index 3a5a1cc3243..43a4d06d391 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -39,11 +39,14 @@ const restore = function* restore() { const newServer = servers[i].id; userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); if (userId) { - return yield put(selectServerRequest(newServer, newServer.version)); + yield put(selectServerRequest(newServer, servers[i].version)); + break; } } } - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + if (!userId) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + } } else { yield localAuthenticate(server); const serverRecord = yield getServerById(server); From 58cb3f7b381e5692e54ddbc23752a019b21cdf02 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 13:49:23 -0300 Subject: [PATCH 04/12] fix: return to the server list when selecting a server fails from a loading root SERVER.SELECT_FAILURE only reaches reducers/server.ts, which never touches app.root, so restore()'s two selectServerRequest exits left the app on a loading root when the switch failed. handleSelectServer's catch now falls back to ROOT_OUTSIDE from the loading roots only, leaving inside and share-extension roots as they were. restore()'s other-server branch becomes a find, dropping the reuse of userId as a did-we-select flag. --- app/sagas/__tests__/init.test.ts | 15 ++++------ app/sagas/__tests__/selectServer.test.ts | 36 ++++++++++++++++++++++++ app/sagas/init.js | 15 +++------- app/sagas/selectServer.ts | 4 +++ 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index cfa856b5629..5611dd65f2b 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -54,6 +54,7 @@ import { RootEnum } from '../../definitions'; import initRoot from '../init'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; +import { TOKEN_KEY } from '../../lib/constants/keys'; import database from '../../lib/database'; import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; import type { RecordingStore } from '../../lib/testUtils/sagaStore'; @@ -97,15 +98,11 @@ describe('init saga — restore user-facing roots', () => { }); it('selects another logged in server with its own version when the stored server has no token', async () => { - jest - .mocked(UserPreferences.getString) - .mockImplementation(key => - key === `reactnativemeteor_usertoken-${OTHER_HOST}` - ? 'token' - : key.startsWith('reactnativemeteor_usertoken-') - ? null - : HOST - ); + jest.mocked(UserPreferences.getString).mockImplementation(key => { + if (key === `${TOKEN_KEY}-${OTHER_HOST}`) return 'token'; + if (key.startsWith(`${TOKEN_KEY}-`)) return null; + return HOST; + }); jest.mocked(database.servers.get).mockReturnValue({ query: () => ({ fetch: () => Promise.resolve([{ id: OTHER_HOST, version: '7.0.0' }]) }) } as any); diff --git a/app/sagas/__tests__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts index 204cd8e7822..9392333a915 100644 --- a/app/sagas/__tests__/selectServer.test.ts +++ b/app/sagas/__tests__/selectServer.test.ts @@ -65,6 +65,8 @@ import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import selectServerRoot from '../selectServer'; import { selectServerRequest } from '../../actions/server'; +import { appStart } from '../../actions/app'; +import { RootEnum } from '../../definitions'; import { SERVER } from '../../actions/actionsTypes'; import UserPreferences from '../../lib/methods/userPreferences'; import { BASIC_AUTH_KEY, setBasicAuth } from '../../lib/methods/helpers/fetch'; @@ -202,3 +204,37 @@ describe('selectServer saga — version and name fallback', () => { expect(success).toMatchObject({ server: SERVER_URL, version: '6.9.0', name: 'Stored A' }); }); }); + +describe('selectServer saga — user-facing root after a failed switch', () => { + beforeEach(() => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); + }); + + it('leaves ROOT_OUTSIDE when the switch fails while the app is on the loading root', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_LOADING })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('leaves ROOT_OUTSIDE when the switch fails while the share sheet is on its loading root', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('keeps the current root when the switch fails while the app is already inside', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_INSIDE); + }); +}); diff --git a/app/sagas/init.js b/app/sagas/init.js index 43a4d06d391..49e1dceeac0 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -34,17 +34,10 @@ const restore = function* restore() { const servers = yield serversCollection.query().fetch(); // Check if there're other logged in servers and picks first one - if (servers.length > 0) { - for (let i = 0; i < servers.length; i += 1) { - const newServer = servers[i].id; - userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (userId) { - yield put(selectServerRequest(newServer, servers[i].version)); - break; - } - } - } - if (!userId) { + const loggedInServer = servers.find(({ id }) => UserPreferences.getString(`${TOKEN_KEY}-${id}`)); + if (loggedInServer) { + yield put(selectServerRequest(loggedInServer.id, loggedInServer.version)); + } else { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } } else { diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..43f09c4d19e 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -218,6 +218,10 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(selectServerSuccess({ server, version: serverVersion, name: serverInfo?.name || 'Rocket.Chat' })); } catch (e) { yield put(selectServerFailure()); + const currentRoot = yield* appSelector(state => state.app.root); + if (currentRoot === RootEnum.ROOT_LOADING || currentRoot === RootEnum.ROOT_LOADING_SHARE_EXTENSION) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + } log(e); } }; From e4707e9e1a4e298443bf2afed0d528adb78e73b7 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 13:52:40 -0300 Subject: [PATCH 05/12] fix: deliver the pending push notification instead of throwing into the boot catch The inner const shadowed the payload with removeItem's undefined result, so JSON.parse threw, restore()'s catch dispatched ROOT_OUTSIDE over the server it had just selected, and the notification was dropped. --- app/sagas/__tests__/deepLinking.test.ts | 10 +++++----- app/sagas/__tests__/init.test.ts | 16 ++++++++++++++++ app/sagas/__tests__/selectServer.test.ts | 4 ++-- app/sagas/init.js | 2 +- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index cb0acf8c4c6..e84d57cef9a 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -621,7 +621,7 @@ describe('deepLinking saga — handleShareExtension user-facing roots', () => { jest.mocked(sdk).current.client.host = ''; }); - it('leaves ROOT_OUTSIDE, not the loading root, when the server record is missing', async () => { + it('lands on ROOT_OUTSIDE, not the loading root, when the server record is missing', async () => { jest.mocked(getServerById).mockResolvedValue(null as any); const { store } = setupStore(); @@ -631,7 +631,7 @@ describe('deepLinking saga — handleShareExtension user-facing roots', () => { expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); }); - it('leaves ROOT_OUTSIDE when the login that the share sheet waits on fails', async () => { + it('lands on ROOT_OUTSIDE when the login that the share sheet waits on fails', async () => { jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); const { store } = setupStore(); @@ -645,7 +645,7 @@ describe('deepLinking saga — handleShareExtension user-facing roots', () => { expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); }); - it('leaves ROOT_OUTSIDE when selecting the server fails while the share sheet waits', async () => { + it('lands on ROOT_OUTSIDE when selecting the server fails while the share sheet waits', async () => { jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); const { store } = setupStore(); @@ -658,7 +658,7 @@ describe('deepLinking saga — handleShareExtension user-facing roots', () => { expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); }); - it('leaves ROOT_OUTSIDE when the server logs the share sheet out instead of failing the login', async () => { + it('lands on ROOT_OUTSIDE when the server logs the share sheet out instead of failing the login', async () => { jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); const { store } = setupStore(); @@ -671,7 +671,7 @@ describe('deepLinking saga — handleShareExtension user-facing roots', () => { expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); }); - it('leaves ROOT_OUTSIDE when local authentication throws', async () => { + it('lands on ROOT_OUTSIDE when local authentication throws', async () => { jest.mocked(localAuthenticate).mockRejectedValueOnce(new Error('biometrics unavailable')); jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); const { store } = setupStore(); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 5611dd65f2b..75f92cd59af 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -54,6 +54,8 @@ import { RootEnum } from '../../definitions'; import initRoot from '../init'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { deepLinkingClickCallPush } from '../../actions/deepLinking'; import { TOKEN_KEY } from '../../lib/constants/keys'; import database from '../../lib/database'; import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; @@ -69,6 +71,8 @@ describe('init saga — restore user-facing roots', () => { jest.mocked(UserPreferences.getString).mockReset(); jest.mocked(getServerById).mockReset(); jest.mocked(RNBootSplash.hide).mockClear(); + jest.mocked(deepLinkingClickCallPush).mockClear(); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(null as any); jest.mocked(UserPreferences.getString).mockImplementation(() => HOST); }); @@ -116,6 +120,18 @@ describe('init saga — restore user-facing roots', () => { expect(store.getState().app.ready).toBe(true); }); + it('delivers the pending push notification without stranding the boot', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(jest.mocked(deepLinkingClickCallPush)).toHaveBeenCalledWith({ rid: 'room-1' }); + expect(store.getState().server.server).toBe(HOST); + }); + it('selects the stored server and marks the app ready when the record exists', async () => { jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); const { store } = setupStore(); diff --git a/app/sagas/__tests__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts index 9392333a915..0cafdbb3d7c 100644 --- a/app/sagas/__tests__/selectServer.test.ts +++ b/app/sagas/__tests__/selectServer.test.ts @@ -211,7 +211,7 @@ describe('selectServer saga — user-facing root after a failed switch', () => { jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); }); - it('leaves ROOT_OUTSIDE when the switch fails while the app is on the loading root', async () => { + it('lands on ROOT_OUTSIDE when the switch fails while the app is on the loading root', async () => { const { store } = setupStore(); store.dispatch(appStart({ root: RootEnum.ROOT_LOADING })); store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); @@ -220,7 +220,7 @@ describe('selectServer saga — user-facing root after a failed switch', () => { expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); }); - it('leaves ROOT_OUTSIDE when the switch fails while the share sheet is on its loading root', async () => { + it('lands on ROOT_OUTSIDE when the switch fails while the share sheet is on its loading root', async () => { const { store } = setupStore(); store.dispatch(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); diff --git a/app/sagas/init.js b/app/sagas/init.js index 49e1dceeac0..270c3e52768 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -53,7 +53,7 @@ const restore = function* restore() { yield put(appReady({})); const pushNotification = yield call(AsyncStorage.getItem, 'pushNotification'); if (pushNotification) { - const pushNotification = yield call(AsyncStorage.removeItem, 'pushNotification'); + yield call(AsyncStorage.removeItem, 'pushNotification'); yield call(deepLinkingClickCallPush, JSON.parse(pushNotification)); } } catch (e) { From b7846c24defd9ba7ddf5d0dda6a2329b348876d5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 13:55:01 -0300 Subject: [PATCH 06/12] fix: dispatch the pending push notification deep link call() built the OPEN_VIDEO_CONF action and discarded it, so the deepLinking watcher never ran. put() dispatches it, and a parse guard keeps a malformed stored payload from throwing into restore()'s catch and overriding the server it had just selected. --- app/sagas/__tests__/init.test.ts | 11 +++-------- app/sagas/init.js | 6 +++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 75f92cd59af..7e182d29540 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -19,10 +19,6 @@ jest.mock('../../lib/methods/userPreferencesMethods', () => ({ getSortPreferences: jest.fn(() => ({})) })); -jest.mock('../../actions/deepLinking', () => ({ - deepLinkingClickCallPush: jest.fn() -})); - jest.mock('react-native-bootsplash', () => ({ __esModule: true, default: { hide: jest.fn(() => Promise.resolve()) } @@ -55,7 +51,7 @@ import initRoot from '../init'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { deepLinkingClickCallPush } from '../../actions/deepLinking'; +import { DEEP_LINKING } from '../../actions/actionsTypes'; import { TOKEN_KEY } from '../../lib/constants/keys'; import database from '../../lib/database'; import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; @@ -71,7 +67,6 @@ describe('init saga — restore user-facing roots', () => { jest.mocked(UserPreferences.getString).mockReset(); jest.mocked(getServerById).mockReset(); jest.mocked(RNBootSplash.hide).mockClear(); - jest.mocked(deepLinkingClickCallPush).mockClear(); jest.mocked(AsyncStorage.getItem).mockResolvedValue(null as any); jest.mocked(UserPreferences.getString).mockImplementation(() => HOST); }); @@ -123,12 +118,12 @@ describe('init saga — restore user-facing roots', () => { it('delivers the pending push notification without stranding the boot', async () => { jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); - const { store } = setupStore(); + const { store, dispatchedActions } = setupStore(); store.dispatch(appInit()); await flushSagaMicrotasks(); - expect(jest.mocked(deepLinkingClickCallPush)).toHaveBeenCalledWith({ rid: 'room-1' }); + expect(dispatchedActions).toContainEqual({ type: DEEP_LINKING.OPEN_VIDEO_CONF, params: { rid: 'room-1' } }); expect(store.getState().server.server).toBe(HOST); }); diff --git a/app/sagas/init.js b/app/sagas/init.js index 270c3e52768..9a76afc58b4 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -54,7 +54,11 @@ const restore = function* restore() { const pushNotification = yield call(AsyncStorage.getItem, 'pushNotification'); if (pushNotification) { yield call(AsyncStorage.removeItem, 'pushNotification'); - yield call(deepLinkingClickCallPush, JSON.parse(pushNotification)); + try { + yield put(deepLinkingClickCallPush(JSON.parse(pushNotification))); + } catch (e) { + log(e); + } } } catch (e) { log(e); From 4b827700ee5d20aac78a0460062f408dadfe77b8 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 13:58:04 -0300 Subject: [PATCH 07/12] refactor: userId is no longer reassigned in restore --- app/sagas/init.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/sagas/init.js b/app/sagas/init.js index 9a76afc58b4..c99b96918c4 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -24,7 +24,7 @@ export const initLocalSettings = function* initLocalSettings() { const restore = function* restore() { try { const server = UserPreferences.getString(CURRENT_SERVER); - let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); if (!server) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); From bb894396b11dd3cfac4a8bb484877545f2065a1f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:07:00 -0300 Subject: [PATCH 08/12] fix: drop the pending push notification when the boot lands outside The push handling now runs only when restore() reached a server, so a stored OPEN_VIDEO_CONF payload is cleared rather than dispatched into a session that does not exist. Adds coverage for the malformed-payload guard. --- app/sagas/__tests__/init.test.ts | 27 +++++++++++++++++++++++++++ app/sagas/init.js | 12 +++++++----- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 7e182d29540..d2fc962712c 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -68,6 +68,7 @@ describe('init saga — restore user-facing roots', () => { jest.mocked(getServerById).mockReset(); jest.mocked(RNBootSplash.hide).mockClear(); jest.mocked(AsyncStorage.getItem).mockResolvedValue(null as any); + jest.mocked(AsyncStorage.removeItem).mockClear(); jest.mocked(UserPreferences.getString).mockImplementation(() => HOST); }); @@ -127,6 +128,32 @@ describe('init saga — restore user-facing roots', () => { expect(store.getState().server.server).toBe(HOST); }); + it('keeps the selected server when the stored push notification payload is malformed', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + jest.mocked(AsyncStorage.getItem).mockResolvedValue('not json' as any); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().server.server).toBe(HOST); + expect(store.getState().app.root).not.toBe(RootEnum.ROOT_OUTSIDE); + expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF })); + }); + + it('drops the pending push notification when the boot lands on ROOT_OUTSIDE', async () => { + jest.mocked(getServerById).mockResolvedValue(null); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(jest.mocked(AsyncStorage.removeItem)).toHaveBeenCalledWith('pushNotification'); + expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF })); + }); + it('selects the stored server and marks the app ready when the record exists', async () => { jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); const { store } = setupStore(); diff --git a/app/sagas/init.js b/app/sagas/init.js index c99b96918c4..1c1a503742d 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -33,7 +33,6 @@ const restore = function* restore() { const serversCollection = serversDB.get('servers'); const servers = yield serversCollection.query().fetch(); - // Check if there're other logged in servers and picks first one const loggedInServer = servers.find(({ id }) => UserPreferences.getString(`${TOKEN_KEY}-${id}`)); if (loggedInServer) { yield put(selectServerRequest(loggedInServer.id, loggedInServer.version)); @@ -54,10 +53,13 @@ const restore = function* restore() { const pushNotification = yield call(AsyncStorage.getItem, 'pushNotification'); if (pushNotification) { yield call(AsyncStorage.removeItem, 'pushNotification'); - try { - yield put(deepLinkingClickCallPush(JSON.parse(pushNotification))); - } catch (e) { - log(e); + const root = yield select(state => state.app.root); + if (root !== RootEnum.ROOT_OUTSIDE) { + try { + yield put(deepLinkingClickCallPush(JSON.parse(pushNotification))); + } catch (e) { + log(e); + } } } } catch (e) { From de7254f28022523296cc12b515c21a5d07163ee5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:10:47 -0300 Subject: [PATCH 09/12] refactor: gate the push notification on the restored server, not the root Reading state.app.root after appReady only happened to be correct: on the selectServerRequest branches the connect is async, so the gate passed by timing. serverToRestore returns the record the branch resolved, so the push is gated on the branch actually taken. --- app/sagas/__tests__/init.test.ts | 14 +++++++++- app/sagas/init.js | 44 ++++++++++++++++---------------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index d2fc962712c..74ccd95fcff 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -45,7 +45,7 @@ jest.mock('../../lib/database', () => ({ import RNBootSplash from 'react-native-bootsplash'; -import { appInit } from '../../actions/app'; +import { appInit, appStart } from '../../actions/app'; import { RootEnum } from '../../definitions'; import initRoot from '../init'; import UserPreferences from '../../lib/methods/userPreferences'; @@ -141,6 +141,18 @@ describe('init saga — restore user-facing roots', () => { expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF })); }); + it('delivers the pending push notification even when the connect fails after the server is restored', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(appInit()); + store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE })); + await flushSagaMicrotasks(); + + expect(dispatchedActions).toContainEqual({ type: DEEP_LINKING.OPEN_VIDEO_CONF, params: { rid: 'room-1' } }); + }); + it('drops the pending push notification when the boot lands on ROOT_OUTSIDE', async () => { jest.mocked(getServerById).mockResolvedValue(null); jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); diff --git a/app/sagas/init.js b/app/sagas/init.js index 1c1a503742d..ff3b5f4bfb0 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -21,40 +21,40 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; +const serverToRestore = function* serverToRestore(server, userId) { + if (!server) { + return null; + } + + if (!userId) { + const serversDB = database.servers; + const serversCollection = serversDB.get('servers'); + const servers = yield serversCollection.query().fetch(); + + return servers.find(({ id }) => UserPreferences.getString(`${TOKEN_KEY}-${id}`)) || null; + } + + yield localAuthenticate(server); + return yield getServerById(server); +}; + const restore = function* restore() { try { const server = UserPreferences.getString(CURRENT_SERVER); const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const restoredServer = yield* serverToRestore(server, userId); - if (!server) { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - } else if (!userId) { - const serversDB = database.servers; - const serversCollection = serversDB.get('servers'); - const servers = yield serversCollection.query().fetch(); - - const loggedInServer = servers.find(({ id }) => UserPreferences.getString(`${TOKEN_KEY}-${id}`)); - if (loggedInServer) { - yield put(selectServerRequest(loggedInServer.id, loggedInServer.version)); - } else { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - } + if (restoredServer) { + yield put(selectServerRequest(restoredServer.id, restoredServer.version)); } else { - yield localAuthenticate(server); - const serverRecord = yield getServerById(server); - if (!serverRecord) { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - } else { - yield put(selectServerRequest(server, serverRecord.version)); - } + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } yield put(appReady({})); const pushNotification = yield call(AsyncStorage.getItem, 'pushNotification'); if (pushNotification) { yield call(AsyncStorage.removeItem, 'pushNotification'); - const root = yield select(state => state.app.root); - if (root !== RootEnum.ROOT_OUTSIDE) { + if (restoredServer) { try { yield put(deepLinkingClickCallPush(JSON.parse(pushNotification))); } catch (e) { From 312955691b404109c70ff98e0ce2ec27371ce188 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:14:40 -0300 Subject: [PATCH 10/12] refactor: let serverToRestore resolve the stored token itself The userId argument only ever carried a value the generator already reads for every other server. All three branches now return null rather than a mix of null and undefined. Covers the no-stored-server branch. --- app/sagas/__tests__/init.test.ts | 13 ++++++++++++- app/sagas/init.js | 9 ++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 74ccd95fcff..74763810fd1 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -97,6 +97,17 @@ describe('init saga — restore user-facing roots', () => { expect(store.getState().app.ready).toBe(true); }); + it('lands on ROOT_OUTSIDE when no server is stored at all', async () => { + jest.mocked(UserPreferences.getString).mockImplementation(() => null); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(store.getState().app.ready).toBe(true); + }); + it('selects another logged in server with its own version when the stored server has no token', async () => { jest.mocked(UserPreferences.getString).mockImplementation(key => { if (key === `${TOKEN_KEY}-${OTHER_HOST}`) return 'token'; @@ -141,7 +152,7 @@ describe('init saga — restore user-facing roots', () => { expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF })); }); - it('delivers the pending push notification even when the connect fails after the server is restored', async () => { + it('delivers the pending push notification even when the root has already moved outside', async () => { jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); const { store, dispatchedActions } = setupStore(); diff --git a/app/sagas/init.js b/app/sagas/init.js index ff3b5f4bfb0..dbe224a9dbb 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -21,12 +21,12 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; -const serverToRestore = function* serverToRestore(server, userId) { +const serverToRestore = function* serverToRestore(server) { if (!server) { return null; } - if (!userId) { + if (!UserPreferences.getString(`${TOKEN_KEY}-${server}`)) { const serversDB = database.servers; const serversCollection = serversDB.get('servers'); const servers = yield serversCollection.query().fetch(); @@ -35,14 +35,13 @@ const serverToRestore = function* serverToRestore(server, userId) { } yield localAuthenticate(server); - return yield getServerById(server); + return (yield getServerById(server)) || null; }; const restore = function* restore() { try { const server = UserPreferences.getString(CURRENT_SERVER); - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); - const restoredServer = yield* serverToRestore(server, userId); + const restoredServer = yield* serverToRestore(server); if (restoredServer) { yield put(selectServerRequest(restoredServer.id, restoredServer.version)); From 5d3657f640185beffd7b51f97606f88b1119b30e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:18:25 -0300 Subject: [PATCH 11/12] test: cover both no-token exits and name the token check isLoggedIn states the token lookup once for the guard and the find predicate. Resets the servers collection mock between cases so the no-stored-server test pins its own guard rather than a leaked mock. --- app/sagas/__tests__/init.test.ts | 15 +++++++++++++++ app/sagas/init.js | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 74763810fd1..9d702dc6a97 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -69,6 +69,7 @@ describe('init saga — restore user-facing roots', () => { jest.mocked(RNBootSplash.hide).mockClear(); jest.mocked(AsyncStorage.getItem).mockResolvedValue(null as any); jest.mocked(AsyncStorage.removeItem).mockClear(); + jest.mocked(database.servers.get).mockReset(); jest.mocked(UserPreferences.getString).mockImplementation(() => HOST); }); @@ -108,6 +109,20 @@ describe('init saga — restore user-facing roots', () => { expect(store.getState().app.ready).toBe(true); }); + it('lands on ROOT_OUTSIDE when neither the stored server nor any other has a token', async () => { + jest.mocked(UserPreferences.getString).mockImplementation(key => (key.startsWith(`${TOKEN_KEY}-`) ? null : HOST)); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: () => Promise.resolve([{ id: OTHER_HOST, version: '7.0.0' }]) }) + } as any); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(store.getState().app.ready).toBe(true); + }); + it('selects another logged in server with its own version when the stored server has no token', async () => { jest.mocked(UserPreferences.getString).mockImplementation(key => { if (key === `${TOKEN_KEY}-${OTHER_HOST}`) return 'token'; diff --git a/app/sagas/init.js b/app/sagas/init.js index dbe224a9dbb..7dc6ff9f66e 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -21,17 +21,19 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; +const isLoggedIn = server => !!UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const serverToRestore = function* serverToRestore(server) { if (!server) { return null; } - if (!UserPreferences.getString(`${TOKEN_KEY}-${server}`)) { + if (!isLoggedIn(server)) { const serversDB = database.servers; const serversCollection = serversDB.get('servers'); const servers = yield serversCollection.query().fetch(); - return servers.find(({ id }) => UserPreferences.getString(`${TOKEN_KEY}-${id}`)) || null; + return servers.find(({ id }) => isLoggedIn(id)) || null; } yield localAuthenticate(server); From bcb4ee4b9f693a07368d5f85879bbe8bc5ff37a3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:33:57 -0300 Subject: [PATCH 12/12] fix: fall back to the server list from any root that is not user-facing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At cold boot `app.root` is `undefined`, not `ROOT_LOADING` — nothing sets a loading root on that path, so the failed-switch guard never fired where the defect actually lands and `AppContainer` matched no navigator group. Gate on the roots worth keeping instead of the ones worth replacing. --- app/sagas/__tests__/deepLinking.test.ts | 2 -- app/sagas/__tests__/init.test.ts | 4 ---- app/sagas/__tests__/selectServer.test.ts | 18 ++++++++++++++++++ app/sagas/selectServer.ts | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index e84d57cef9a..406ea6dcf5a 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -603,8 +603,6 @@ describe('deepLinking saga — unknown host hands off to the add-server flow', ( }); }); -// ─── handleShareExtension — every exit must land on a user-facing root ────────── - describe('deepLinking saga — handleShareExtension user-facing roots', () => { beforeEach(() => { jest.mocked(UserPreferences.getString).mockReset(); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 9d702dc6a97..b23547588be 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -1,5 +1,3 @@ -// ─── Boundary mocks — must appear before any import that triggers the module ─── - jest.mock('../../lib/methods/userPreferences', () => ({ __esModule: true, default: { @@ -41,8 +39,6 @@ jest.mock('../../lib/database', () => ({ } })); -// ─── Real imports (after mocks) ─────────────────────────────────────────────── - import RNBootSplash from 'react-native-bootsplash'; import { appInit, appStart } from '../../actions/app'; diff --git a/app/sagas/__tests__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts index 0cafdbb3d7c..7302338e5a0 100644 --- a/app/sagas/__tests__/selectServer.test.ts +++ b/app/sagas/__tests__/selectServer.test.ts @@ -211,6 +211,15 @@ describe('selectServer saga — user-facing root after a failed switch', () => { jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); }); + it('lands on ROOT_OUTSIDE when the switch fails during boot, before any root is set', async () => { + const { store } = setupStore(); + expect(store.getState().app.root).toBeUndefined(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + it('lands on ROOT_OUTSIDE when the switch fails while the app is on the loading root', async () => { const { store } = setupStore(); store.dispatch(appStart({ root: RootEnum.ROOT_LOADING })); @@ -237,4 +246,13 @@ describe('selectServer saga — user-facing root after a failed switch', () => { expect(store.getState().app.root).toBe(RootEnum.ROOT_INSIDE); }); + + it('keeps the current root when the switch fails while the share sheet is up', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); }); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 43f09c4d19e..a203c59f3f9 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -219,7 +219,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch } catch (e) { yield put(selectServerFailure()); const currentRoot = yield* appSelector(state => state.app.root); - if (currentRoot === RootEnum.ROOT_LOADING || currentRoot === RootEnum.ROOT_LOADING_SHARE_EXTENSION) { + if (currentRoot !== RootEnum.ROOT_INSIDE && currentRoot !== RootEnum.ROOT_SHARE_EXTENSION) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } log(e);