1import { type NavigationAction, type NavigationState } from '@react-navigation/native';
2import * as Linking from 'expo-linking';
3
4import type { RouterStore } from './router-store';
5import { ResultState } from '../fork/getStateFromPath';
6import { Href, resolveHref } from '../link/href';
7import { resolve } from '../link/path';
8import { hasUrlProtocolPrefix } from '../utils/url';
9
10function assertIsReady(store: RouterStore) {
11  if (!store.navigationRef.isReady()) {
12    throw new Error(
13      'Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.'
14    );
15  }
16}
17
18export function push(this: RouterStore, url: Href) {
19  return this.linkTo(resolveHref(url));
20}
21
22export function replace(this: RouterStore, url: Href) {
23  return this.linkTo(resolveHref(url), 'REPLACE');
24}
25
26export function goBack(this: RouterStore) {
27  assertIsReady(this);
28  this.navigationRef?.current?.goBack();
29}
30
31export function canGoBack(this: RouterStore): boolean {
32  // Return a default value here if the navigation hasn't mounted yet.
33  // This can happen if the user calls `canGoBack` from the Root Layout route
34  // before mounting a navigator. This behavior exists due to React Navigation being dynamically
35  // constructed at runtime. We can get rid of this in the future if we use
36  // the static configuration internally.
37  if (!this.navigationRef.isReady()) {
38    return false;
39  }
40  return this.navigationRef?.current?.canGoBack() ?? false;
41}
42
43export function setParams(this: RouterStore, params: Record<string, string | number> = {}) {
44  assertIsReady(this);
45  return (this.navigationRef?.current?.setParams as any)(params);
46}
47
48export function linkTo(this: RouterStore, href: string, event?: string) {
49  if (hasUrlProtocolPrefix(href)) {
50    Linking.openURL(href);
51    return;
52  }
53
54  assertIsReady(this);
55  const navigationRef = this.navigationRef.current;
56
57  if (navigationRef == null) {
58    throw new Error(
59      "Couldn't find a navigation object. Is your component inside NavigationContainer?"
60    );
61  }
62
63  if (!this.linking) {
64    throw new Error('Attempted to link to route when no routes are present');
65  }
66
67  if (href === '..' || href === '../') {
68    navigationRef.goBack();
69    return;
70  }
71
72  const rootState = navigationRef.getRootState();
73
74  if (href.startsWith('.')) {
75    let base =
76      this.linking.getPathFromState?.(rootState, {
77        screens: [],
78        preserveGroups: true,
79      }) ?? '';
80
81    if (base && !base.endsWith('/')) {
82      base += '/..';
83    }
84    href = resolve(base, href);
85  }
86
87  const state = this.linking.getStateFromPath!(href, this.linking.config);
88
89  if (!state || state.routes.length === 0) {
90    console.error('Could not generate a valid navigation state for the given path: ' + href);
91    return;
92  }
93
94  switch (event) {
95    case 'REPLACE':
96      return navigationRef.dispatch(getNavigateReplaceAction(state, rootState));
97    default:
98      return navigationRef.dispatch(getNavigatePushAction(state, rootState));
99  }
100}
101
102type NavigationParams = Partial<{
103  screen: string;
104  params: NavigationParams;
105}>;
106
107function rewriteNavigationStateToParams(
108  state?: { routes: ResultState['routes'] },
109  params: NavigationParams = {}
110) {
111  if (!state) return params;
112  // We Should always have at least one route in the state
113  const lastRoute = state.routes.at(-1)!;
114  params.screen = lastRoute.name;
115  // Weirdly, this always needs to be an object. If it's undefined, it won't work.
116  params.params = lastRoute.params ? JSON.parse(JSON.stringify(lastRoute.params)) : {};
117
118  if (lastRoute.state) {
119    rewriteNavigationStateToParams(lastRoute.state, params.params);
120  }
121
122  return JSON.parse(JSON.stringify(params));
123}
124
125function getNavigatePushAction(state: ResultState, rootState: NavigationState) {
126  const { screen, params } = rewriteNavigationStateToParams(state);
127  return {
128    type: 'NAVIGATE',
129    target: rootState.key,
130    payload: {
131      name: screen,
132      params,
133    },
134  };
135}
136
137function getNavigateReplaceAction(
138  state: ResultState,
139  parentState: NavigationState,
140  lastNavigatorSupportingReplace: NavigationState = parentState
141): NavigationAction {
142  // We should always have at least one route in the state
143  const route = state.routes.at(-1)!;
144
145  // Only these navigators support replace
146  if (parentState.type === 'stack' || parentState.type === 'tab') {
147    lastNavigatorSupportingReplace = parentState;
148  }
149
150  const currentRoute = parentState.routes.find((parentRoute) => parentRoute.name === route.name);
151  const routesAreEqual = parentState.routes[parentState.index] === currentRoute;
152
153  // If there is nested state and the routes are equal, we should keep going down the tree
154  if (route.state && routesAreEqual && currentRoute.state) {
155    return getNavigateReplaceAction(
156      route.state,
157      currentRoute.state as any,
158      lastNavigatorSupportingReplace
159    );
160  }
161
162  // Either we reached the bottom of the state or the point where the routes diverged
163  const { screen, params } = rewriteNavigationStateToParams(state);
164
165  return {
166    type: lastNavigatorSupportingReplace.type === 'stack' ? 'REPLACE' : 'JUMP_TO',
167    target: lastNavigatorSupportingReplace?.key,
168    payload: {
169      name: screen,
170      params,
171    },
172  };
173}
174