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 +2 -1
- package/package.json +14 -20
- package/src/LocationProvider.tsx +15 -15
- package/src/NavigationContainer.tsx +3 -13
- package/src/__tests__/LocationProvider.test.node.ts +29 -1
- package/src/__tests__/getRoutes.test.node.ts +1 -1
- package/src/fork/NavigationContainer.native.tsx +159 -0
- package/src/fork/NavigationContainer.tsx +4 -0
- package/src/fork/__tests__/__snapshots__/extractPathFromURL.test.ios.ts.snap +37 -0
- package/src/fork/__tests__/extractPathFromURL.test.ios.ts +46 -0
- package/src/fork/extractPathFromURL.ts +22 -0
- package/src/fork/getPathFromState.ts +1 -1
- package/src/fork/useLinking.native.ts +223 -0
- package/src/fork/useLinking.ts +3 -0
- package/src/getLinkingConfig.ts +1 -10
- package/src/link/__tests__/stateOperations.test.node.ts +105 -0
- package/src/link/__tests__/useLinkToPath.test.node.ts +53 -0
- package/src/link/linking.ts +2 -11
- package/src/link/stateOperations.ts +51 -0
- package/src/link/useLinkToPath.ts +72 -0
- package/src/aasa.ts +0 -41
package/node/render.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-router",
|
|
3
|
-
"version": "1.
|
|
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": "
|
|
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.
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"react-native-
|
|
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": "
|
|
74
|
+
"@expo/metro-runtime": "1.0.0",
|
|
81
75
|
"@radix-ui/react-slot": "^1.0.0",
|
|
82
|
-
"react-
|
|
83
|
-
"@react-navigation/
|
|
84
|
-
"@react-navigation/native": "~6.
|
|
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
|
}
|
package/src/LocationProvider.tsx
CHANGED
|
@@ -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(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
200
|
-
|
|
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
|
|
205
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
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,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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/getLinkingConfig.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/link/linking.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
}
|