diff --git a/__tests__/lib/routes-helpers.test.ts b/__tests__/lib/routes-helpers.test.ts new file mode 100644 index 0000000000..e2dcfa79e8 --- /dev/null +++ b/__tests__/lib/routes-helpers.test.ts @@ -0,0 +1,107 @@ +import { + buildStateObject, + getCurrentRoute, + isStateAtTabRoot, +} from '#/lib/routes/helpers' +import {type State} from '#/lib/routes/types' + +describe('getCurrentRoute', () => { + it('returns Home when there is no state', () => { + expect(getCurrentRoute(undefined).name).toBe('Home') + }) + + it('descends into nested state using the index', () => { + const state = { + index: 0, + routes: [ + { + name: 'HomeTab', + state: { + index: 1, + routes: [{name: 'Home'}, {name: 'PostThread'}], + }, + }, + ], + } as unknown as State + + expect(getCurrentRoute(state).name).toBe('PostThread') + }) + + /* + * Right after a cold start from a deep link, nested navigator states are + * still partial and have no `index`. React Navigation focuses the last + * route when rehydrating such a state, so getCurrentRoute must do the + * same (previously it stopped at the tab route, which re-enabled the + * drawer swipe gesture on top of the deep-linked screen). + */ + it('descends into partial nested state without an index', () => { + const state = { + routes: [ + { + name: 'HomeTab', + state: { + routes: [{name: 'Home'}, {name: 'PostThread'}], + }, + }, + ], + } as unknown as State + + expect(getCurrentRoute(state).name).toBe('PostThread') + }) +}) + +describe('isStateAtTabRoot', () => { + it('returns true for the initial deep link state of a tab root', () => { + const state = buildStateObject('HomeTab', 'Home', {}) as unknown as State + expect(isStateAtTabRoot(state)).toBe(true) + }) + + it('returns false for the initial deep link state of a nested screen', () => { + const state = buildStateObject( + 'HomeTab', + 'PostThread', + {name: 'alice.test', rkey: '123'}, + [{name: 'Home', params: {}}], + ) as unknown as State + expect(isStateAtTabRoot(state)).toBe(false) + }) +}) + +describe('buildStateObject', () => { + it('focuses the deep-linked route in the nested state', () => { + const state = buildStateObject( + 'HomeTab', + 'PostThread', + {name: 'alice.test', rkey: '123'}, + [{name: 'Home', params: {}}], + ) + + expect(state).toEqual({ + index: 0, + routes: [ + { + name: 'HomeTab', + state: { + index: 1, + routes: [ + {name: 'Home', params: {}}, + {name: 'PostThread', params: {name: 'alice.test', rkey: '123'}}, + ], + }, + }, + ], + }) + }) + + it('builds a single-route state for the Flat navigator', () => { + const state = buildStateObject('Flat', 'PostThread', { + name: 'alice.test', + rkey: '123', + }) + + expect(state).toEqual({ + index: 0, + routes: [{name: 'PostThread', params: {name: 'alice.test', rkey: '123'}}], + }) + }) +}) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 1fbd5b1948..7bf45b41dc 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -30,7 +30,7 @@ import { } from '#/lib/hooks/useNotificationHandler' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' import {useCallOnce} from '#/lib/once' -import {buildStateObject} from '#/lib/routes/helpers' +import {buildStateObject, getCurrentRoute} from '#/lib/routes/helpers' import { type AllNavigatorParams, type BottomTabNavigatorParams, @@ -783,10 +783,7 @@ const LINKING = { getPathFromState(state: State) { // find the current node in the navigation tree - let node = state.routes[state.index || 0] - while (node.state?.routes && typeof node.state?.index === 'number') { - node = node.state?.routes[node.state?.index] - } + const node = getCurrentRoute(state) // build the path const route = router.matchName(node.name) diff --git a/src/lib/routes/helpers.ts b/src/lib/routes/helpers.ts index 33e7563a3c..08263cdded 100644 --- a/src/lib/routes/helpers.ts +++ b/src/lib/routes/helpers.ts @@ -13,12 +13,20 @@ export function getRootNavigation( export function getCurrentRoute(state?: State) { if (!state) { - return {name: 'Home'} + return {name: 'Home', params: undefined} } + /* + * Nested navigator states may still be partial ("stale") right after a cold + * start from a deep link, in which case `index` is not set yet. React + * Navigation focuses the last route when it rehydrates such a state, so + * mirror that here instead of stopping the descent early and misreporting + * the tab root as the current route (which e.g. re-enabled the drawer + * swipe gesture on top of a deep-linked screen). + */ let node = state.routes[state.index || 0] - while (node.state?.routes && typeof node.state?.index === 'number') { - node = node.state?.routes[node.state?.index] + while (node.state?.routes) { + node = node.state.routes[node.state.index ?? node.state.routes.length - 1] } return node } @@ -83,15 +91,24 @@ export function buildStateObject( ) { if (stack === 'Flat') { return { + index: 0, routes: [{name: route, params}], } } + /* + * Set `index` explicitly so consumers of this state (e.g. getCurrentRoute) + * can tell which route is focused before React Navigation has rehydrated + * the nested navigator state. + */ + const routes = [...state, {name: route, params}] return { + index: 0, routes: [ { name: stack, state: { - routes: [...state, {name: route, params}], + index: routes.length - 1, + routes, }, }, ],