expo-router 1.0.0-rc8 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/node/render.js CHANGED
@@ -1,2 +1,3 @@
1
1
  // Assumes Metro handles this import.
2
- module.exports = require("../src/static/renderStaticContent");
2
+ // NOTE(EvanBacon): No relative imports!
3
+ module.exports = require("expo-router/src/static/renderStaticContent");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-router",
3
- "version": "1.0.0-rc8",
3
+ "version": "1.2.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "files": [
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@react-navigation/drawer": "^6.5.8",
45
- "expo": "~46.0.2 || ~47.0.0-beta || ~47.0.0 || ~48.0.0-beta || ~48.0.0",
45
+ "expo": "^48.0.0",
46
46
  "expo-constants": "*",
47
47
  "expo-linking": "*",
48
48
  "expo-status-bar": "*",
@@ -53,9 +53,6 @@
53
53
  "react-native-screens": "*"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
- "react-native-gesture-handler": {
57
- "optional": true
58
- },
59
56
  "react-native-reanimated": {
60
57
  "optional": true
61
58
  },
@@ -64,26 +61,23 @@
64
61
  }
65
62
  },
66
63
  "devDependencies": {
67
- "@react-navigation/drawer": "^6.4.4",
68
- "@types/jest": "^26",
69
- "jest": "^26.6.3",
70
- "expo-module-scripts": "^2.0.0",
71
- "expo-splash-screen": "^0.16.2",
72
- "expo-status-bar": "^1.4.0",
73
- "react-native-gesture-handler": "~2.5.0",
74
- "react-native-reanimated": "~2.9.1",
75
- "react-native-safe-area-context": "4.5.0",
76
- "react-native-screens": "~3.19.0"
64
+ "@react-navigation/drawer": "^6.6.2",
65
+ "expo-splash-screen": "~0.18.1",
66
+ "expo-status-bar": "~1.4.4",
67
+ "react-native-gesture-handler": "~2.9.0",
68
+ "react-native-reanimated": "~2.14.4",
69
+ "react-native-safe-area-context": "~4.5.0",
70
+ "react-native-screens": "~3.20.0"
77
71
  },
78
72
  "dependencies": {
79
73
  "@bacons/react-views": "^1.1.3",
80
- "@expo/metro-runtime": "~0.0.1",
74
+ "@expo/metro-runtime": "1.0.0",
81
75
  "@radix-ui/react-slot": "^1.0.0",
82
- "react-helmet-async": "^1.3.0",
83
- "@react-navigation/bottom-tabs": "~6.5.4",
84
- "@react-navigation/native": "~6.1.3",
85
- "@react-navigation/native-stack": "~6.9.9",
76
+ "@react-navigation/bottom-tabs": "~6.5.7",
77
+ "@react-navigation/native": "~6.1.6",
78
+ "@react-navigation/native-stack": "~6.9.12",
86
79
  "expo-splash-screen": "*",
80
+ "react-helmet-async": "^1.3.0",
87
81
  "url": "^0.11.0"
88
82
  }
89
83
  }
@@ -3,6 +3,7 @@ import React from "react";
3
3
 
4
4
  import { getNavigationContainerRef } from "./NavigationContainer";
5
5
  import getPathFromState, {
6
+ deepEqual,
6
7
  getPathDataFromState,
7
8
  State,
8
9
  } from "./fork/getPathFromState";
@@ -10,7 +11,7 @@ import { useLinkingContext } from "./link/useLinkingContext";
10
11
  import { useServerState } from "./static/useServerState";
11
12
  import { useInitialRootStateContext } from "./useInitialRootStateContext";
12
13
 
13
- type SearchParams = Record<string, string>;
14
+ type SearchParams = Record<string, string | string[]>;
14
15
 
15
16
  type UrlObject = {
16
17
  pathname: string;
@@ -42,15 +43,11 @@ function compareRouteInfo(a: UrlObject, b: UrlObject) {
42
43
  );
43
44
  }
44
45
 
45
- function compareUrlSearchParams(a: SearchParams, b: SearchParams): boolean {
46
- const aKeys = Object.keys(a);
47
- const bKeys = Object.keys(b);
48
-
49
- if (aKeys.length !== bKeys.length) {
50
- return false;
51
- }
52
-
53
- return aKeys.every((key) => a[key] === b[key]);
46
+ export function compareUrlSearchParams(
47
+ a: SearchParams,
48
+ b: SearchParams
49
+ ): boolean {
50
+ return deepEqual(a, b);
54
51
  }
55
52
 
56
53
  function useSafeInitialRootState() {
@@ -129,7 +126,6 @@ function useGetPathFromState() {
129
126
  return React.useCallback(
130
127
  (state: Parameters<typeof getPathFromState>[0], asPath: boolean) => {
131
128
  return getPathDataFromState(state, {
132
- // return linking.getPathFromState(state, {
133
129
  ...linking.config,
134
130
  preserveDynamicRoutes: asPath,
135
131
  preserveGroups: asPath,
@@ -196,13 +192,17 @@ export function usePathname(): string {
196
192
  }
197
193
 
198
194
  /** @returns Current URL Search Parameters. */
199
- export function useSearchParams(): SearchParams {
200
- return useLocation().params;
195
+ export function useSearchParams<
196
+ TParams extends SearchParams = SearchParams
197
+ >(): Partial<TParams> {
198
+ return useLocation().params as Partial<TParams>;
201
199
  }
202
200
 
203
201
  /** @returns Current URL Search Parameters that only update when the path matches the current route. */
204
- export function useLocalSearchParams(): SearchParams {
205
- return useRoute()?.params ?? ({} as any);
202
+ export function useLocalSearchParams<
203
+ TParams extends SearchParams = SearchParams
204
+ >(): Partial<TParams> {
205
+ return (useRoute()?.params ?? ({} as any)) as Partial<TParams>;
206
206
  }
207
207
 
208
208
  /** @returns Array of selected segments. */
@@ -1,18 +1,12 @@
1
- import {
2
- createNavigationContainerRef,
3
- NavigationContainer as UpstreamNavigationContainer,
4
- } from "@react-navigation/native";
1
+ import { createNavigationContainerRef } from "@react-navigation/native";
5
2
  import React from "react";
6
3
 
4
+ import UpstreamNavigationContainer from "./fork/NavigationContainer";
7
5
  import { getLinkingConfig } from "./getLinkingConfig";
8
6
  import { RootNavigationRef } from "./useRootNavigation";
9
7
  import { useRootRouteNodeContext } from "./useRootRouteNodeContext";
10
8
  import { SplashScreen } from "./views/Splash";
11
9
 
12
- type NavigationContainerProps = React.ComponentProps<
13
- typeof UpstreamNavigationContainer
14
- >;
15
-
16
10
  const navigationRef = createNavigationContainerRef();
17
11
 
18
12
  /** Get the root navigation container ref. */
@@ -21,17 +15,13 @@ export function getNavigationContainerRef() {
21
15
  }
22
16
 
23
17
  /** react-navigation `NavigationContainer` with automatic `linking` prop generated from the routes context. */
24
- export function NavigationContainer(props: NavigationContainerProps) {
18
+ export function NavigationContainer(props: { children: React.ReactNode }) {
25
19
  const [isReady, setReady] = React.useState(false);
26
20
  const [isSplashReady, setSplashReady] = React.useState(false);
27
21
  const ref = React.useMemo(() => (isReady ? navigationRef : null), [isReady]);
28
22
  const root = useRootRouteNodeContext();
29
23
  const linking = React.useMemo(() => getLinkingConfig(root), [root]);
30
24
 
31
- React.useEffect(() => {
32
- props.onReady?.();
33
- }, [!!props?.onReady]);
34
-
35
25
  return (
36
26
  <RootNavigationRef.Provider value={{ ref }}>
37
27
  {!isSplashReady && <SplashScreen />}
@@ -1,4 +1,7 @@
1
- import { getNormalizedStatePath } from "../LocationProvider";
1
+ import {
2
+ getNormalizedStatePath,
3
+ compareUrlSearchParams,
4
+ } from "../LocationProvider";
2
5
 
3
6
  describe(getNormalizedStatePath, () => {
4
7
  // Ensure all values are correctly decoded
@@ -25,3 +28,28 @@ describe(getNormalizedStatePath, () => {
25
28
  });
26
29
  });
27
30
  });
31
+
32
+ describe(compareUrlSearchParams, () => {
33
+ it("compares search params", () => {
34
+ expect(
35
+ compareUrlSearchParams(
36
+ { one: "two", three: ["four"] },
37
+ { one: "two", three: ["four"] }
38
+ )
39
+ ).toBe(true);
40
+
41
+ expect(
42
+ compareUrlSearchParams(
43
+ { one: "two", three: ["four"], five: "six", seven: "eight" },
44
+ { one: "two", three: ["four"], five: "six", seven: "eight" }
45
+ )
46
+ ).toBe(true);
47
+
48
+ expect(
49
+ compareUrlSearchParams(
50
+ { six: "seven", eight: ["nine"] },
51
+ { one: "two", three: ["four"] }
52
+ )
53
+ ).toBe(false);
54
+ });
55
+ });
@@ -76,7 +76,7 @@ describe(assertDuplicateRoutes, () => {
76
76
  expect(() =>
77
77
  assertDuplicateRoutes(["a.js", "a.tsx", "b.js"])
78
78
  ).toThrowErrorMatchingInlineSnapshot(
79
- `"Multiple files match the route name \\"a\\"."`
79
+ `"Multiple files match the route name "a"."`
80
80
  );
81
81
  });
82
82
 
@@ -0,0 +1,159 @@
1
+ // Forked from React Navigation in order to use a custom `useLinking` -> `extractPathFromURL` function.
2
+ // https://github.com/react-navigation/react-navigation/blob/main/packages/native/src/NavigationContainer.tsx
3
+ import {
4
+ BaseNavigationContainer,
5
+ getActionFromState,
6
+ getPathFromState,
7
+ getStateFromPath,
8
+ NavigationContainerProps,
9
+ NavigationContainerRef,
10
+ ParamListBase,
11
+ validatePathConfig,
12
+ } from "@react-navigation/core";
13
+ import {
14
+ DefaultTheme,
15
+ DocumentTitleOptions,
16
+ LinkingContext,
17
+ LinkingOptions,
18
+ Theme,
19
+ ThemeProvider,
20
+ } from "@react-navigation/native";
21
+ import useBackButton from "@react-navigation/native/src/useBackButton";
22
+ import useDocumentTitle from "@react-navigation/native/src/useDocumentTitle";
23
+ import useThenable from "@react-navigation/native/src/useThenable";
24
+ import * as React from "react";
25
+
26
+ import useLinking from "./useLinking";
27
+
28
+ declare global {
29
+ // eslint-disable-next-line no-var
30
+ var REACT_NAVIGATION_DEVTOOLS: WeakMap<
31
+ NavigationContainerRef<any>,
32
+ { readonly linking: LinkingOptions<any> }
33
+ >;
34
+ }
35
+
36
+ global.REACT_NAVIGATION_DEVTOOLS = new WeakMap();
37
+
38
+ type Props<ParamList extends object> = NavigationContainerProps & {
39
+ theme?: Theme;
40
+ linking?: LinkingOptions<ParamList>;
41
+ fallback?: React.ReactNode;
42
+ documentTitle?: DocumentTitleOptions;
43
+ onReady?: () => void;
44
+ };
45
+
46
+ /**
47
+ * Container component which holds the navigation state designed for React Native apps.
48
+ * This should be rendered at the root wrapping the whole app.
49
+ *
50
+ * @param props.initialState Initial state object for the navigation tree. When deep link handling is enabled, this will override deep links when specified. Make sure that you don't specify an `initialState` when there's a deep link (`Linking.getInitialURL()`).
51
+ * @param props.onReady Callback which is called after the navigation tree mounts.
52
+ * @param props.onStateChange Callback which is called with the latest navigation state when it changes.
53
+ * @param props.theme Theme object for the navigators.
54
+ * @param props.linking Options for deep linking. Deep link handling is enabled when this prop is provided, unless `linking.enabled` is `false`.
55
+ * @param props.fallback Fallback component to render until we have finished getting initial state when linking is enabled. Defaults to `null`.
56
+ * @param props.documentTitle Options to configure the document title on Web. Updating document title is handled by default unless `documentTitle.enabled` is `false`.
57
+ * @param props.children Child elements to render the content.
58
+ * @param props.ref Ref object which refers to the navigation object containing helper methods.
59
+ */
60
+ function NavigationContainerInner(
61
+ {
62
+ theme = DefaultTheme,
63
+ linking,
64
+ fallback = null,
65
+ documentTitle,
66
+ onReady,
67
+ ...rest
68
+ }: Props<ParamListBase>,
69
+ ref?: React.Ref<NavigationContainerRef<ParamListBase> | null>
70
+ ) {
71
+ const isLinkingEnabled = linking ? linking.enabled !== false : false;
72
+
73
+ if (linking?.config) {
74
+ validatePathConfig(linking.config);
75
+ }
76
+
77
+ const refContainer =
78
+ React.useRef<NavigationContainerRef<ParamListBase>>(null);
79
+
80
+ useBackButton(refContainer);
81
+ useDocumentTitle(refContainer, documentTitle);
82
+
83
+ const { getInitialState } = useLinking(refContainer, {
84
+ // independent: rest.independent,
85
+ enabled: isLinkingEnabled,
86
+ prefixes: [],
87
+ ...linking,
88
+ });
89
+
90
+ // Add additional linking related info to the ref
91
+ // This will be used by the devtools
92
+ React.useEffect(() => {
93
+ if (refContainer.current) {
94
+ REACT_NAVIGATION_DEVTOOLS.set(refContainer.current, {
95
+ get linking() {
96
+ return {
97
+ ...linking,
98
+ enabled: isLinkingEnabled,
99
+ prefixes: linking?.prefixes ?? [],
100
+ getStateFromPath: linking?.getStateFromPath ?? getStateFromPath,
101
+ getPathFromState: linking?.getPathFromState ?? getPathFromState,
102
+ getActionFromState:
103
+ linking?.getActionFromState ?? getActionFromState,
104
+ };
105
+ },
106
+ });
107
+ }
108
+ });
109
+
110
+ const [isResolved, initialState] = useThenable(getInitialState);
111
+
112
+ React.useImperativeHandle(ref, () => refContainer.current);
113
+
114
+ const linkingContext = React.useMemo(() => ({ options: linking }), [linking]);
115
+
116
+ const isReady = rest.initialState != null || !isLinkingEnabled || isResolved;
117
+
118
+ const onReadyRef = React.useRef(onReady);
119
+
120
+ React.useEffect(() => {
121
+ onReadyRef.current = onReady;
122
+ });
123
+
124
+ React.useEffect(() => {
125
+ if (isReady) {
126
+ onReadyRef.current?.();
127
+ }
128
+ }, [isReady]);
129
+
130
+ if (!isReady) {
131
+ // This is temporary until we have Suspense for data-fetching
132
+ // Then the fallback will be handled by a parent `Suspense` component
133
+ return fallback as React.ReactElement;
134
+ }
135
+
136
+ return (
137
+ <LinkingContext.Provider value={linkingContext}>
138
+ <ThemeProvider value={theme}>
139
+ <BaseNavigationContainer
140
+ {...rest}
141
+ initialState={
142
+ rest.initialState == null ? initialState : rest.initialState
143
+ }
144
+ ref={refContainer}
145
+ />
146
+ </ThemeProvider>
147
+ </LinkingContext.Provider>
148
+ );
149
+ }
150
+
151
+ const NavigationContainer = React.forwardRef(NavigationContainerInner) as <
152
+ RootParamList extends object = ReactNavigation.RootParamList
153
+ >(
154
+ props: Props<RootParamList> & {
155
+ ref?: React.Ref<NavigationContainerRef<RootParamList>>;
156
+ }
157
+ ) => React.ReactElement;
158
+
159
+ export default NavigationContainer;
@@ -0,0 +1,4 @@
1
+ // We only need to fork on native to support any prefixes.
2
+ import { NavigationContainer } from "@react-navigation/native";
3
+
4
+ export default NavigationContainer;
@@ -0,0 +1,37 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`extractExpoPathFromURL parses "custom://" 1`] = `""`;
4
+
5
+ exports[`extractExpoPathFromURL parses "custom:///" 1`] = `""`;
6
+
7
+ exports[`extractExpoPathFromURL parses "custom:///?shouldBeEscaped=x%252By%2540xxx.com" 1`] = `"?shouldBeEscaped=x+y@xxx.com"`;
8
+
9
+ exports[`extractExpoPathFromURL parses "custom:///test/path?foo=bar" 1`] = `"test/path?foo=bar"`;
10
+
11
+ exports[`extractExpoPathFromURL parses "custom://?hello=bar" 1`] = `"?hello=bar"`;
12
+
13
+ exports[`extractExpoPathFromURL parses "exp://127.0.0.1:19000/" 1`] = `""`;
14
+
15
+ exports[`extractExpoPathFromURL parses "exp://127.0.0.1:19000/--/test/path?query=param" 1`] = `"test/path?query=param"`;
16
+
17
+ exports[`extractExpoPathFromURL parses "exp://127.0.0.1:19000?query=param" 1`] = `"?query=param"`;
18
+
19
+ exports[`extractExpoPathFromURL parses "exp://exp.host/@test/test/--/test/path" 1`] = `"test/path"`;
20
+
21
+ exports[`extractExpoPathFromURL parses "exp://exp.host/@test/test/--/test/path/--/foobar" 1`] = `"test/path/--/foobar"`;
22
+
23
+ exports[`extractExpoPathFromURL parses "exp://exp.host/@test/test/--/test/path?query=param" 1`] = `"test/path?query=param"`;
24
+
25
+ exports[`extractExpoPathFromURL parses "https://example.com/test/path" 1`] = `"test/path"`;
26
+
27
+ exports[`extractExpoPathFromURL parses "https://example.com/test/path?missingQueryValue=" 1`] = `"test/path?missingQueryValue="`;
28
+
29
+ exports[`extractExpoPathFromURL parses "https://example.com/test/path?query=do+not+escape" 1`] = `"test/path?query=do not escape"`;
30
+
31
+ exports[`extractExpoPathFromURL parses "https://example.com/test/path?query=param" 1`] = `"test/path?query=param"`;
32
+
33
+ exports[`extractExpoPathFromURL parses "https://example.com:8000/test/path" 1`] = `"test/path"`;
34
+
35
+ exports[`extractExpoPathFromURL parses "https://example.com:8000/test/path+with+plus" 1`] = `"with+plus"`;
36
+
37
+ exports[`extractExpoPathFromURL parses "invalid" 1`] = `"invalid"`;
@@ -0,0 +1,46 @@
1
+ import Constants, { ExecutionEnvironment } from "expo-constants";
2
+
3
+ import { extractExpoPathFromURL } from "../extractPathFromURL";
4
+
5
+ describe(extractExpoPathFromURL, () => {
6
+ const originalExecutionEnv = Constants.executionEnvironment;
7
+
8
+ afterEach(() => {
9
+ Constants.executionEnvironment = originalExecutionEnv;
10
+ });
11
+
12
+ test.each<string>([
13
+ "exp://127.0.0.1:19000/",
14
+ "exp://127.0.0.1:19000/--/test/path?query=param",
15
+ "exp://127.0.0.1:19000?query=param",
16
+ "exp://exp.host/@test/test/--/test/path?query=param",
17
+ "exp://exp.host/@test/test/--/test/path",
18
+ "exp://exp.host/@test/test/--/test/path/--/foobar",
19
+ "https://example.com/test/path?query=param",
20
+ "https://example.com/test/path",
21
+ "https://example.com:8000/test/path",
22
+ "https://example.com:8000/test/path+with+plus",
23
+ "https://example.com/test/path?query=do+not+escape",
24
+ "https://example.com/test/path?missingQueryValue=",
25
+ "custom:///?shouldBeEscaped=x%252By%2540xxx.com",
26
+ "custom:///test/path?foo=bar",
27
+ "custom:///",
28
+ "custom://",
29
+ "custom://?hello=bar",
30
+ "invalid",
31
+ ])(`parses %p`, (url) => {
32
+ Constants.executionEnvironment = ExecutionEnvironment.StoreClient;
33
+
34
+ const res = extractExpoPathFromURL(url);
35
+ expect(res).toMatchSnapshot();
36
+ // Ensure the Expo Go handling never breaks
37
+ expect(res).not.toMatch(/^--\//);
38
+ });
39
+
40
+ it(`only handles Expo Go URLs in Expo Go`, () => {
41
+ Constants.executionEnvironment = ExecutionEnvironment.Bare;
42
+
43
+ const res = extractExpoPathFromURL("exp://127.0.0.1:19000/--/test");
44
+ expect(res).toEqual("--/test");
45
+ });
46
+ });
@@ -0,0 +1,22 @@
1
+ import Constants, { ExecutionEnvironment } from "expo-constants";
2
+ import * as Linking from "expo-linking";
3
+
4
+ // This is only run on native.
5
+ export function extractExpoPathFromURL(url: string) {
6
+ // Handle special URLs used in Expo Go: `/--/pathname` -> `pathname`
7
+ if (Constants.executionEnvironment === ExecutionEnvironment.StoreClient) {
8
+ const pathname = url.match(/exps?:\/\/.*?\/--\/(.*)/)?.[1];
9
+ if (pathname) {
10
+ return pathname;
11
+ }
12
+ // Fallback on default behavior
13
+ }
14
+
15
+ const res = Linking.parse(url);
16
+ const qs = !res.queryParams
17
+ ? ""
18
+ : Object.entries(res.queryParams)
19
+ .map(([k, v]) => `${k}=${v}`)
20
+ .join("&");
21
+ return (res.path || "") + (qs ? "?" + qs : "");
22
+ }
@@ -183,7 +183,7 @@ function processParamsWithUserSettings(
183
183
  );
184
184
  }
185
185
 
186
- function deepEqual(a: any, b: any) {
186
+ export function deepEqual(a: any, b: any) {
187
187
  if (a === b) {
188
188
  return true;
189
189
  }
@@ -0,0 +1,223 @@
1
+ // Forked from react-navigation with a custom `extractPathFromURL` that automatically
2
+ // allows any prefix and parses Expo Go URLs.
3
+ // For simplicity the following are disabled: enabled, prefixes, independent
4
+ // https://github.com/react-navigation/react-navigation/blob/main/packages/native/src/useLinking.native.tsx
5
+ import {
6
+ getActionFromState as getActionFromStateDefault,
7
+ getStateFromPath as getStateFromPathDefault,
8
+ NavigationContainerRef,
9
+ ParamListBase,
10
+ } from "@react-navigation/core";
11
+ import type { LinkingOptions } from "@react-navigation/native";
12
+ import * as React from "react";
13
+ import { Linking, Platform } from "react-native";
14
+
15
+ import { extractExpoPathFromURL } from "./extractPathFromURL";
16
+
17
+ type ResultState = ReturnType<typeof getStateFromPathDefault>;
18
+
19
+ type Options = LinkingOptions<ParamListBase>;
20
+
21
+ const linkingHandlers: symbol[] = [];
22
+
23
+ export default function useLinking(
24
+ ref: React.RefObject<NavigationContainerRef<ParamListBase>>,
25
+ {
26
+ // enabled = true,
27
+ // prefixes,
28
+ filter,
29
+ config,
30
+ getInitialURL = () =>
31
+ Promise.race([
32
+ Linking.getInitialURL(),
33
+ new Promise<undefined>((resolve) =>
34
+ // Timeout in 150ms if `getInitialState` doesn't resolve
35
+ // Workaround for https://github.com/facebook/react-native/issues/25675
36
+ setTimeout(resolve, 150)
37
+ ),
38
+ ]),
39
+ subscribe = (listener) => {
40
+ const callback = ({ url }: { url: string }) => listener(url);
41
+
42
+ const subscription = Linking.addEventListener("url", callback) as
43
+ | { remove(): void }
44
+ | undefined;
45
+
46
+ return () => {
47
+ subscription?.remove();
48
+ };
49
+ },
50
+ getStateFromPath = getStateFromPathDefault,
51
+ getActionFromState = getActionFromStateDefault,
52
+ }: Options
53
+ ) {
54
+ // const independent = useNavigationIndependentTree();
55
+
56
+ React.useEffect(
57
+ () => {
58
+ if (process.env.NODE_ENV === "production") {
59
+ return undefined;
60
+ }
61
+
62
+ // if (independent) {
63
+ // return undefined;
64
+ // }
65
+
66
+ if (
67
+ // enabled !== false &&
68
+ linkingHandlers.length
69
+ ) {
70
+ console.error(
71
+ [
72
+ "Looks like you have configured linking in multiple places. This is likely an error since deep links should only be handled in one place to avoid conflicts. Make sure that:",
73
+ "- You don't have multiple NavigationContainers in the app each with 'linking' enabled",
74
+ "- Only a single instance of the root component is rendered",
75
+ Platform.OS === "android"
76
+ ? "- You have set 'android:launchMode=singleTask' in the '<activity />' section of the 'AndroidManifest.xml' file to avoid launching multiple instances"
77
+ : "",
78
+ ]
79
+ .join("\n")
80
+ .trim()
81
+ );
82
+ }
83
+
84
+ const handler = Symbol();
85
+
86
+ // if (enabled !== false) {
87
+ linkingHandlers.push(handler);
88
+ // }
89
+
90
+ return () => {
91
+ const index = linkingHandlers.indexOf(handler);
92
+
93
+ if (index > -1) {
94
+ linkingHandlers.splice(index, 1);
95
+ }
96
+ };
97
+ },
98
+ [
99
+ // enabled,
100
+ // independent
101
+ ]
102
+ );
103
+
104
+ // We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
105
+ // This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
106
+ // Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
107
+ // const enabledRef = React.useRef(enabled);
108
+ // const prefixesRef = React.useRef(prefixes);
109
+ const filterRef = React.useRef(filter);
110
+ const configRef = React.useRef(config);
111
+ const getInitialURLRef = React.useRef(getInitialURL);
112
+ const getStateFromPathRef = React.useRef(getStateFromPath);
113
+ const getActionFromStateRef = React.useRef(getActionFromState);
114
+
115
+ React.useEffect(() => {
116
+ // enabledRef.current = enabled;
117
+ // prefixesRef.current = prefixes;
118
+ filterRef.current = filter;
119
+ configRef.current = config;
120
+ getInitialURLRef.current = getInitialURL;
121
+ getStateFromPathRef.current = getStateFromPath;
122
+ getActionFromStateRef.current = getActionFromState;
123
+ });
124
+
125
+ const getStateFromURL = React.useCallback(
126
+ (url: string | null | undefined) => {
127
+ if (!url || (filterRef.current && !filterRef.current(url))) {
128
+ return undefined;
129
+ }
130
+
131
+ // NOTE(EvanBacon): This is the important part.
132
+ const path = extractExpoPathFromURL(url);
133
+
134
+ return path !== undefined
135
+ ? getStateFromPathRef.current(path, configRef.current)
136
+ : undefined;
137
+ },
138
+ []
139
+ );
140
+
141
+ const getInitialState = React.useCallback(() => {
142
+ // let state: ResultState | undefined;
143
+ // if (enabledRef.current) {
144
+ const url = getInitialURLRef.current();
145
+
146
+ if (url != null && typeof url !== "string") {
147
+ return url.then((url) => {
148
+ const state = getStateFromURL(url);
149
+
150
+ return state;
151
+ });
152
+ }
153
+
154
+ const state = getStateFromURL(url);
155
+ // }
156
+
157
+ const thenable = {
158
+ then(onfulfilled?: (state: ResultState | undefined) => void) {
159
+ return Promise.resolve(onfulfilled ? onfulfilled(state) : state);
160
+ },
161
+ catch() {
162
+ return thenable;
163
+ },
164
+ };
165
+
166
+ return thenable as PromiseLike<ResultState | undefined>;
167
+ }, [getStateFromURL]);
168
+
169
+ React.useEffect(() => {
170
+ const listener = (url: string) => {
171
+ // if (!enabled) {
172
+ // return;
173
+ // }
174
+
175
+ const navigation = ref.current;
176
+ const state = navigation ? getStateFromURL(url) : undefined;
177
+
178
+ if (navigation && state) {
179
+ // Make sure that the routes in the state exist in the root navigator
180
+ // Otherwise there's an error in the linking configuration
181
+ const rootState = navigation.getRootState();
182
+
183
+ if (state.routes.some((r) => !rootState?.routeNames.includes(r.name))) {
184
+ console.warn(
185
+ "The navigation state parsed from the URL contains routes not present in the root navigator. This usually means that the linking configuration doesn't match the navigation structure. See https://reactnavigation.org/docs/configuring-links for more details on how to specify a linking configuration."
186
+ );
187
+ return;
188
+ }
189
+
190
+ const action = getActionFromStateRef.current(state, configRef.current);
191
+
192
+ if (action !== undefined) {
193
+ try {
194
+ navigation.dispatch(action);
195
+ } catch (e) {
196
+ // Ignore any errors from deep linking.
197
+ // This could happen in case of malformed links, navigation object not being initialized etc.
198
+ console.warn(
199
+ `An error occurred when trying to handle the link '${url}': ${
200
+ typeof e === "object" && e != null && "message" in e
201
+ ? e.message
202
+ : e
203
+ }`
204
+ );
205
+ }
206
+ } else {
207
+ navigation.resetRoot(state);
208
+ }
209
+ }
210
+ };
211
+
212
+ return subscribe(listener);
213
+ }, [
214
+ // enabled,
215
+ getStateFromURL,
216
+ ref,
217
+ subscribe,
218
+ ]);
219
+
220
+ return {
221
+ getInitialState,
222
+ };
223
+ }
@@ -0,0 +1,3 @@
1
+ import useLinking from "@react-navigation/native/lib/module/useLinking";
2
+
3
+ export default useLinking as typeof import("./useLinking.native").default;
@@ -1,12 +1,10 @@
1
1
  import { LinkingOptions, getActionFromState } from "@react-navigation/native";
2
2
 
3
3
  import { RouteNode } from "./Route";
4
- import { getAllWebRedirects } from "./aasa";
5
4
  import {
6
5
  addEventListener,
7
6
  getInitialURL,
8
7
  getPathFromState,
9
- getRootURL,
10
8
  getStateFromPath,
11
9
  } from "./link/linking";
12
10
  import { matchDeepDynamicRouteName, matchDynamicName } from "./matchers";
@@ -93,14 +91,7 @@ export function getNavigationConfig(routes: RouteNode): {
93
91
 
94
92
  export function getLinkingConfig(routes: RouteNode): LinkingOptions<object> {
95
93
  return {
96
- prefixes: [
97
- /* your linking prefixes */
98
- getRootURL(),
99
-
100
- // This ensures that we can redirect correctly when the user comes from an associated domain
101
- // i.e. iOS Safari banner.
102
- ...getAllWebRedirects(),
103
- ],
94
+ prefixes: [],
104
95
  // @ts-expect-error
105
96
  config: getNavigationConfig(routes),
106
97
  // A custom getInitialURL is used on native to ensure the app always starts at
@@ -2,8 +2,113 @@ import {
2
2
  isMovingToSiblingRoute,
3
3
  findTopRouteForTarget,
4
4
  getQualifiedStateForTopOfTargetState,
5
+ getEarliestMismatchedRoute,
5
6
  } from "../stateOperations";
6
7
 
8
+ describe(getEarliestMismatchedRoute, () => {
9
+ it(`finds earliest mismatched route`, () => {
10
+ expect(
11
+ getEarliestMismatchedRoute(
12
+ {
13
+ type: "tab",
14
+ index: 0,
15
+ routes: [
16
+ {
17
+ name: "root",
18
+ state: {
19
+ type: "stack",
20
+ index: 0,
21
+ routes: [
22
+ {
23
+ name: "(auth)/sign-in",
24
+ },
25
+ ],
26
+ },
27
+ },
28
+ {
29
+ name: "_sitemap",
30
+ },
31
+ {
32
+ name: "[...404]",
33
+ },
34
+ ],
35
+ },
36
+ {
37
+ name: "root",
38
+ path: "",
39
+ initial: true,
40
+ screen: "root",
41
+ params: {
42
+ initial: true,
43
+ screen: "(app)",
44
+ path: "",
45
+ params: {
46
+ initial: true,
47
+ screen: "index",
48
+ path: "/root",
49
+ },
50
+ },
51
+ }
52
+ )
53
+ ).toEqual({
54
+ name: "(app)",
55
+ type: "stack",
56
+ params: {
57
+ initial: true,
58
+ path: "/root",
59
+ screen: "index",
60
+ },
61
+ });
62
+ });
63
+
64
+ it(`returns top-level match`, () => {
65
+ expect(
66
+ getEarliestMismatchedRoute(
67
+ {
68
+ type: "tab",
69
+ index: 1,
70
+ routes: [
71
+ {
72
+ name: "root",
73
+ },
74
+ {
75
+ name: "_sitemap",
76
+ },
77
+ {
78
+ name: "[...404]",
79
+ },
80
+ ],
81
+ },
82
+ {
83
+ name: "root",
84
+ path: "",
85
+ initial: true,
86
+ screen: "root",
87
+ params: {
88
+ initial: true,
89
+ screen: "(app)",
90
+ path: "",
91
+ params: {
92
+ initial: true,
93
+ screen: "index",
94
+ path: "/root",
95
+ },
96
+ },
97
+ }
98
+ )
99
+ ).toEqual({
100
+ name: "root",
101
+ params: {
102
+ initial: true,
103
+ params: { initial: true, path: "/root", screen: "index" },
104
+ path: "",
105
+ screen: "(app)",
106
+ },
107
+ type: "tab",
108
+ });
109
+ });
110
+ });
111
+
7
112
  describe(findTopRouteForTarget, () => {
8
113
  it(`finds the top route`, () => {
9
114
  expect(
@@ -0,0 +1,53 @@
1
+ import { isAbsoluteInitialRoute } from "../useLinkToPath";
2
+
3
+ describe(isAbsoluteInitialRoute, () => {
4
+ it(`returns true when a nested action is absolutely initial`, () => {
5
+ expect(
6
+ isAbsoluteInitialRoute({
7
+ type: "NAVIGATE",
8
+ payload: {
9
+ name: "root",
10
+ params: {
11
+ initial: true,
12
+ screen: "(app)",
13
+ params: {
14
+ initial: true,
15
+ screen: "index",
16
+ path: "/root",
17
+ },
18
+ },
19
+ },
20
+ })
21
+ ).toBe(true);
22
+ });
23
+ it(`returns true when a nested action is absolutely initial (shallow)`, () => {
24
+ expect(
25
+ isAbsoluteInitialRoute({
26
+ type: "NAVIGATE",
27
+ payload: {
28
+ name: "root",
29
+ params: undefined,
30
+ },
31
+ })
32
+ ).toBe(true);
33
+ });
34
+ it(`returns false when a nested action is not absolutely initial`, () => {
35
+ expect(
36
+ isAbsoluteInitialRoute({
37
+ type: "NAVIGATE",
38
+ payload: {
39
+ name: "root",
40
+ params: {
41
+ initial: true,
42
+ screen: "(app)",
43
+ params: {
44
+ initial: false,
45
+ screen: "index",
46
+ path: "/root",
47
+ },
48
+ },
49
+ },
50
+ })
51
+ ).toBe(false);
52
+ });
53
+ });
@@ -76,20 +76,11 @@ export function addEventListener(listener: (url: string) => void) {
76
76
  } else {
77
77
  callback = ({ url }: { url: string }) => listener(url);
78
78
  }
79
- const subscription = Linking.addEventListener("url", callback) as
80
- | { remove(): void }
81
- | undefined;
82
-
83
- // Storing this in a local variable stops Jest from complaining about import after teardown
84
- const removeEventListener = Linking.removeEventListener?.bind(Linking);
79
+ const subscription = Linking.addEventListener("url", callback);
85
80
 
86
81
  return () => {
87
82
  // https://github.com/facebook/react-native/commit/6d1aca806cee86ad76de771ed3a1cc62982ebcd7
88
- if (subscription?.remove) {
89
- subscription.remove();
90
- } else {
91
- removeEventListener?.("url", callback);
92
- }
83
+ subscription.remove?.();
93
84
  };
94
85
  }
95
86
 
@@ -2,6 +2,13 @@ import { InitialState } from "@react-navigation/native";
2
2
 
3
3
  import { ResultState } from "../fork/getStateFromPath";
4
4
 
5
+ export type ActionParams = {
6
+ params?: ActionParams;
7
+ path: string;
8
+ initial: boolean;
9
+ screen: string;
10
+ };
11
+
5
12
  // Get the last state for a given target state (generated from a path).
6
13
  function findTopStateForTarget(state: ResultState) {
7
14
  let current: Partial<InitialState> | undefined = state;
@@ -87,3 +94,47 @@ export function getQualifiedStateForTopOfTargetState(
87
94
 
88
95
  return currentRoot;
89
96
  }
97
+
98
+ type SubState = {
99
+ type: string;
100
+ routes?: { name: string; state?: SubState }[];
101
+ index?: number;
102
+ };
103
+
104
+ // Given the root state and a target state from `getStateFromPath`,
105
+ // return the root state containing the highest target route matching the root state.
106
+ // This can be used to determine what type of navigator action should be used.
107
+ export function getEarliestMismatchedRoute(
108
+ rootState: SubState | undefined,
109
+ actionParams: ActionParams & { name?: string }
110
+ ): { name: string; params?: any; type?: string } | null {
111
+ const actionName = actionParams.name ?? actionParams.screen;
112
+ if (!rootState?.routes || rootState.index == null) {
113
+ // This should never happen where there's more action than state.
114
+ return {
115
+ name: actionName,
116
+ type: "stack",
117
+ };
118
+ }
119
+
120
+ const nextCurrentRoot = rootState.routes[rootState.index];
121
+ if (actionName === nextCurrentRoot.name) {
122
+ if (!actionParams.params) {
123
+ // All routes match all the way up, no change required.
124
+ return null;
125
+ }
126
+
127
+ return getEarliestMismatchedRoute(
128
+ nextCurrentRoot.state,
129
+ actionParams.params
130
+ );
131
+ }
132
+
133
+ // There's a selected state but it doesn't match the action state
134
+ // this is now the lowest point of change.
135
+ return {
136
+ name: actionName,
137
+ params: actionParams.params,
138
+ type: rootState.type,
139
+ };
140
+ }
@@ -11,11 +11,20 @@ import * as React from "react";
11
11
  import { resolve } from "./path";
12
12
  import {
13
13
  findTopRouteForTarget,
14
+ getEarliestMismatchedRoute,
14
15
  getQualifiedStateForTopOfTargetState,
15
16
  isMovingToSiblingRoute,
16
17
  } from "./stateOperations";
17
18
  import { useLinkingContext } from "./useLinkingContext";
18
19
 
20
+ type NavStateParams = {
21
+ params?: NavStateParams;
22
+ path: string;
23
+ initial: boolean;
24
+ screen: string;
25
+ state: unknown;
26
+ };
27
+
19
28
  function isRemoteHref(href: string): boolean {
20
29
  return /:\/\//.test(href);
21
30
  }
@@ -102,6 +111,39 @@ export function useLinkToPath() {
102
111
 
103
112
  const action = getActionFromState(state, linking!.config);
104
113
  if (action) {
114
+ // Here we have a navigation action to a nested screen, where we should ideally replace.
115
+ // This request can only be fulfilled if the target is an initial route.
116
+ // First, check if the action is fully initial routes.
117
+ // Then find the nearest mismatched route in the existing state.
118
+ // Finally, use the correct navigator-based action to replace the nested screens.
119
+ // NOTE(EvanBacon): A future version of this will involve splitting the navigation request so we replace as much as possible, then push the remaining screens to fulfill the request.
120
+ if (
121
+ event === "REPLACE" &&
122
+ action.type === "NAVIGATE" &&
123
+ isAbsoluteInitialRoute(action)
124
+ ) {
125
+ const earliest = getEarliestMismatchedRoute(
126
+ // @ts-expect-error
127
+ rootState,
128
+ action.payload
129
+ );
130
+ if (earliest) {
131
+ if (earliest.type === "stack") {
132
+ navigation.dispatch(
133
+ StackActions.replace(earliest.name, earliest.params)
134
+ );
135
+ } else {
136
+ navigation.dispatch(
137
+ TabActions.jumpTo(earliest.name, earliest.params)
138
+ );
139
+ }
140
+ return;
141
+ } else {
142
+ // This should never happen because moving to the same route would be handled earlier
143
+ // in the sibling operations.
144
+ }
145
+ }
146
+
105
147
  // Ignore the replace event here since replace across
106
148
  // navigators is not supported.
107
149
  navigation.dispatch(action);
@@ -114,3 +156,33 @@ export function useLinkToPath() {
114
156
 
115
157
  return linkTo;
116
158
  }
159
+
160
+ /** @returns `true` if the action is moving to the first screen of all the navigators in the action. */
161
+ export function isAbsoluteInitialRoute(
162
+ action: ReturnType<typeof getActionFromState>
163
+ ) {
164
+ if (action?.type !== "NAVIGATE") {
165
+ return false;
166
+ }
167
+
168
+ let next = action.payload.params;
169
+ // iterate all child screens and bail out if any are not initial.
170
+ while (next) {
171
+ if (!isNavigationState(next)) {
172
+ // Not sure when this would happen
173
+ return false;
174
+ }
175
+ if (next.initial === true) {
176
+ next = next.params;
177
+ // return true;
178
+ } else if (next.initial === false) {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ return true;
184
+ }
185
+
186
+ function isNavigationState(obj: any): obj is NavStateParams {
187
+ return "initial" in obj;
188
+ }
package/src/aasa.ts DELETED
@@ -1,41 +0,0 @@
1
- import Constants from "expo-constants";
2
-
3
- function getWebUrlsFromManifest() {
4
- // TODO: Replace this with the source of truth native manifest
5
- // Then do a check to warn the user if the config doesn't match the native manifest.
6
- // TODO: Warn if the applinks have `https://` in them.
7
- const domains = Constants.expoConfig?.ios?.associatedDomains || [];
8
- // [applinks:explore-api.netlify.app/] -> [explore-api.netlify.app]
9
- const applinks = domains
10
- .filter((domain) => domain.startsWith("applinks:"))
11
- .map((domain) => {
12
- let clean = domain.replace(/^applinks:/, "");
13
- clean = clean.endsWith("/") ? clean.slice(0, -1) : clean;
14
- return clean.replace(
15
- /\?mode=(developer|managed|developer\+managed|managed\+developer)$/,
16
- ""
17
- );
18
- });
19
-
20
- return [...new Set(applinks)];
21
- }
22
-
23
- export function getAllWebRedirects(
24
- protocols = ["https", "http"],
25
- subdomains = ["*"]
26
- ) {
27
- const urls = getWebUrlsFromManifest();
28
- const _subdomains = [""].concat(subdomains);
29
- return urls
30
- .map((url) =>
31
- protocols
32
- .map((protocol) =>
33
- _subdomains.map(
34
- (subdomain) =>
35
- `${protocol}://${[subdomain, url].filter(Boolean).join(".")}/`
36
- )
37
- )
38
- .flat()
39
- )
40
- .flat();
41
- }