feat: rework linking configuration to be more strict (#8502)

The PR changes a few things about linking configuration:

- Moves the configuration for screens to a screens property so that it's possible to specify other options like `initialRouteName` for the navigator at root
- The nesting in the configuration needs to strictly match the shape of the navigation tree, it can't just rely on URL's shape anymore
- If a screen is not specified in the configuration, it won't be parsed to/from the URL (this is essential to handle unmatched screens)
- Treat `path: ''` and no specified path in the same way, unless `exact` is specified
- Disallow specifying unmatched screen with old format
- Add support for `initialRouteName` at top level
- Automatically adapt old configuration to new format
This commit is contained in:
Satyajit Sahoo
2020-06-24 16:54:24 +02:00
committed by GitHub
parent a2d649faf1
commit a021cfb8af
10 changed files with 2718 additions and 706 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
import type { PathConfigMap } from './types';
type Options = {
initialRouteName?: string;
screens: PathConfigMap;
};
export default function checkLegacyPathConfig(
config?: Options
): [boolean, Options | undefined] {
let legacy = false;
if (config) {
// Assume legacy configuration if config has any other keys except `screens` and `initialRouteName`
legacy = Object.keys(config).some(
(key) => key !== 'screens' && key !== 'initialRouteName'
);
if (
legacy &&
(config.hasOwnProperty('screens') ||
config.hasOwnProperty('initialRouteName'))
) {
throw new Error(
'Found invalid keys in the configuration object. See https://reactnavigation.org/docs/configuring-links/ for more details on the shape of the configuration object.'
);
}
}
if (legacy) {
// @ts-expect-error
return [legacy, { screens: config }];
}
return [legacy, config];
}

View File

@@ -4,14 +4,15 @@ import type {
PartialState,
Route,
} from '@react-navigation/routers';
import type { PathConfig } from './types';
import checkLegacyPathConfig from './checkLegacyPathConfig';
import type { PathConfig, PathConfigMap } from './types';
type Options = { initialRouteName?: string; screens: PathConfigMap };
type State = NavigationState | Omit<PartialState<NavigationState>, 'stale'>;
type StringifyConfig = Record<string, (value: any) => string>;
type OptionsItem = PathConfig[string];
type ConfigItem = {
pattern?: string;
stringify?: StringifyConfig;
@@ -46,9 +47,11 @@ const getActiveRoute = (state: State): { name: string; params?: object } => {
* ],
* },
* {
* Chat: {
* path: 'chat/:author/:id',
* stringify: { author: author => author.toLowerCase() }
* screens: {
* Chat: {
* path: 'chat/:author/:id',
* stringify: { author: author => author.toLowerCase() }
* }
* }
* }
* )
@@ -59,15 +62,21 @@ const getActiveRoute = (state: State): { name: string; params?: object } => {
* @returns Path representing the state, e.g. /foo/bar?count=42.
*/
export default function getPathFromState(
state?: State,
options: PathConfig = {}
state: State,
options?: Options
): string {
if (state === undefined) {
throw Error('NavigationState not passed');
if (state == null) {
throw Error(
"Got 'undefined' for the navigation state. You must pass a valid state object."
);
}
// Create a normalized configs array which will be easier to use
const configs = createNormalizedConfigs(options);
const [legacy, compatOptions] = checkLegacyPathConfig(options);
// Create a normalized configs object which will be easier to use
const configs: Record<string, ConfigItem> = compatOptions
? createNormalizedConfigs(legacy, compatOptions.screens)
: {};
let path = '/';
let current: State | undefined = state;
@@ -168,6 +177,12 @@ export default function getPathFromState(
// Showing the route name seems ok, though whatever we show here will be incorrect
// Since the page doesn't actually exist
if (p === '*') {
if (legacy) {
throw new Error(
"Please update your config to the new format to use wildcard pattern ('*'). https://reactnavigation.org/docs/configuring-links/#updating-config"
);
}
return route.name;
}
@@ -238,7 +253,8 @@ const joinPaths = (...paths: string[]): string =>
.join('/');
const createConfigItem = (
config: OptionsItem | string,
legacy: boolean,
config: PathConfig | string,
parentPattern?: string
): ConfigItem => {
if (typeof config === 'string') {
@@ -250,13 +266,28 @@ const createConfigItem = (
// If an object is specified as the value (e.g. Foo: { ... }),
// It can have `path` property and `screens` prop which has nested configs
const pattern =
config.exact !== true && parentPattern && config.path
? joinPaths(parentPattern, config.path)
: config.path;
let pattern: string | undefined;
if (legacy) {
pattern =
config.exact !== true && parentPattern && config.path
? joinPaths(parentPattern, config.path)
: config.path;
} else {
if (config.exact && config.path === undefined) {
throw new Error(
"A 'path' needs to be specified when specifying 'exact: true'. If you don't want this screen in the URL, specify it as empty string, e.g. `path: ''`."
);
}
pattern =
config.exact !== true
? joinPaths(parentPattern || '', config.path || '')
: config.path || '';
}
const screens = config.screens
? createNormalizedConfigs(config.screens, pattern)
? createNormalizedConfigs(legacy, config.screens, pattern)
: undefined;
return {
@@ -268,12 +299,13 @@ const createConfigItem = (
};
const createNormalizedConfigs = (
options: PathConfig,
legacy: boolean,
options: PathConfigMap,
pattern?: string
): Record<string, ConfigItem> =>
fromEntries(
Object.entries(options).map(([name, c]) => {
const result = createConfigItem(c, pattern);
const result = createConfigItem(legacy, c, pattern);
return [name, result];
})

View File

@@ -5,7 +5,13 @@ import type {
PartialState,
InitialState,
} from '@react-navigation/routers';
import type { PathConfig } from './types';
import checkLegacyPathConfig from './checkLegacyPathConfig';
import type { PathConfigMap } from './types';
type Options = {
initialRouteName?: string;
screens: PathConfigMap;
};
type ParseConfig = Record<string, (value: string) => any>;
@@ -36,9 +42,11 @@ type ResultState = PartialState<NavigationState> & {
* getStateFromPath(
* '/chat/jane/42',
* {
* Chat: {
* path: 'chat/:author/:id',
* parse: { id: Number }
* screens: {
* Chat: {
* path: 'chat/:author/:id',
* parse: { id: Number }
* }
* }
* }
* )
@@ -48,15 +56,62 @@ type ResultState = PartialState<NavigationState> & {
*/
export default function getStateFromPath(
path: string,
options: PathConfig = {}
options?: Options
): ResultState | undefined {
const [legacy, compatOptions] = checkLegacyPathConfig(options);
let initialRoutes: InitialRouteConfig[] = [];
if (compatOptions?.initialRouteName) {
initialRoutes.push({
initialRouteName: compatOptions.initialRouteName,
connectedRoutes: Object.keys(compatOptions.screens),
});
}
const screens = compatOptions?.screens;
let remaining = path
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
.replace(/^\//, '') // Remove extra leading slash
.replace(/\?.*$/, ''); // Remove query params which we will handle later
// Make sure there is a trailing slash
remaining = remaining.endsWith('/') ? remaining : `${remaining}/`;
if (screens === undefined) {
// When no config is specified, use the path segments as route names
const routes = remaining
.split('/')
.filter(Boolean)
.map((segment, i, self) => {
const name = decodeURIComponent(segment);
if (i === self.length - 1) {
return { name, params: parseQueryParams(path) };
}
return { name };
});
if (routes.length) {
return createNestedStateObject(routes, initialRoutes);
}
return undefined;
}
// Create a normalized configs array which will be easier to use
const configs = ([] as RouteConfig[])
.concat(
...Object.keys(options).map((key) =>
createNormalizedConfigs(key, options, [], initialRoutes)
...Object.keys(screens).map((key) =>
createNormalizedConfigs(
legacy,
key,
screens as PathConfigMap,
[],
initialRoutes
)
)
)
.sort((a, b) => {
@@ -100,14 +155,6 @@ export default function getStateFromPath(
return bWildcardIndex - aWildcardIndex;
});
let remaining = path
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
.replace(/^\//, '') // Remove extra leading slash
.replace(/\?.*$/, ''); // Remove query params which we will handle later
// Make sure there is a trailing slash
remaining = remaining.endsWith('/') ? remaining : `${remaining}/`;
if (remaining === '/') {
// We need to add special handling of empty path so navigation to empty path also works
// When handling empty path, we should only look at the root level config
@@ -139,66 +186,67 @@ export default function getStateFromPath(
let result: PartialState<NavigationState> | undefined;
let current: PartialState<NavigationState> | undefined;
// We try to match the paths in 2 passes
// In first pass, we match the whole path against the regex instead of segments
// This makes sure matches such as wildcard will catch any unmatched routes, even if nested
const { routeNames, allParams, remainingPath } = matchAgainstConfigs(
remaining,
configs.map((c) => ({
...c,
// Add `$` to the regex to make sure it matches till end of the path and not just beginning
regex: c.regex ? new RegExp(c.regex.source + '$') : undefined,
}))
);
if (routeNames !== undefined) {
// This will always be empty if full path matched
remaining = remainingPath;
current = createNestedStateObject(
createRouteObjects(configs, routeNames, allParams),
initialRoutes
);
result = current;
}
// In second pass, we divide the path into segments and match piece by piece
// This preserves the old behaviour, but we should remove it in next major
while (remaining) {
let { routeNames, allParams, remainingPath } = matchAgainstConfigs(
if (legacy === false) {
// If we're not in legacy mode,, we match the whole path against the regex instead of segments
// This makes sure matches such as wildcard will catch any unmatched routes, even if nested
const { routeNames, allParams, remainingPath } = matchAgainstConfigs(
remaining,
configs
configs.map((c) => ({
...c,
// Add `$` to the regex to make sure it matches till end of the path and not just beginning
regex: c.regex ? new RegExp(c.regex.source + '$') : undefined,
}))
);
remaining = remainingPath;
// If we hadn't matched any segments earlier, use the path as route name
if (routeNames === undefined) {
const segments = remaining.split('/');
routeNames = [decodeURIComponent(segments[0])];
segments.shift();
remaining = segments.join('/');
if (routeNames !== undefined) {
// This will always be empty if full path matched
remaining = remainingPath;
current = createNestedStateObject(
createRouteObjects(configs, routeNames, allParams),
initialRoutes
);
result = current;
}
} else {
// In legacy mode, we divide the path into segments and match piece by piece
// This preserves the legacy behaviour, but we should remove it in next major
while (remaining) {
let { routeNames, allParams, remainingPath } = matchAgainstConfigs(
remaining,
configs
);
const state = createNestedStateObject(
createRouteObjects(configs, routeNames, allParams),
initialRoutes
);
remaining = remainingPath;
if (current) {
// The state should be nested inside the deepest route we parsed before
while (current?.routes[current.index || 0].state) {
current = current.routes[current.index || 0].state;
// If we hadn't matched any segments earlier, use the path as route name
if (routeNames === undefined) {
const segments = remaining.split('/');
routeNames = [decodeURIComponent(segments[0])];
segments.shift();
remaining = segments.join('/');
}
(current as PartialState<NavigationState>).routes[
current?.index || 0
].state = state;
} else {
result = state;
}
const state = createNestedStateObject(
createRouteObjects(configs, routeNames, allParams),
initialRoutes
);
current = state;
if (current) {
// The state should be nested inside the deepest route we parsed before
while (current?.routes[current.index || 0].state) {
current = current.routes[current.index || 0].state;
}
(current as PartialState<NavigationState>).routes[
current?.index || 0
].state = state;
} else {
result = state;
}
current = state;
}
}
if (current == null || result == null) {
@@ -265,8 +313,9 @@ const matchAgainstConfigs = (remaining: string, configs: RouteConfig[]) => {
};
const createNormalizedConfigs = (
legacy: boolean,
screen: string,
routeConfig: PathConfig,
routeConfig: PathConfigMap,
routeNames: string[] = [],
initials: InitialRouteConfig[],
parentPattern?: string
@@ -281,7 +330,7 @@ const createNormalizedConfigs = (
// If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern
const pattern = parentPattern ? joinPaths(parentPattern, config) : config;
configs.push(createConfigItem(screen, routeNames, pattern, config));
configs.push(createConfigItem(legacy, screen, routeNames, pattern, config));
} else if (typeof config === 'object') {
let pattern: string | undefined;
@@ -289,13 +338,33 @@ const createNormalizedConfigs = (
// it can have `path` property and
// it could have `screens` prop which has nested configs
if (typeof config.path === 'string') {
pattern =
config.exact !== true && parentPattern
? joinPaths(parentPattern, config.path)
: config.path;
if (legacy) {
pattern =
config.exact !== true && parentPattern
? joinPaths(parentPattern, config.path)
: config.path;
} else {
if (config.exact && config.path === undefined) {
throw new Error(
"A 'path' needs to be specified when specifying 'exact: true'. If you don't want this screen in the URL, specify it as empty string, e.g. `path: ''`."
);
}
pattern =
config.exact !== true
? joinPaths(parentPattern || '', config.path || '')
: config.path || '';
}
configs.push(
createConfigItem(screen, routeNames, pattern, config.path, config.parse)
createConfigItem(
legacy,
screen,
routeNames,
pattern,
config.path,
config.parse
)
);
}
@@ -310,11 +379,12 @@ const createNormalizedConfigs = (
Object.keys(config.screens).forEach((nestedConfig) => {
const result = createNormalizedConfigs(
legacy,
nestedConfig,
config.screens as PathConfig,
config.screens as PathConfigMap,
routeNames,
initials,
pattern
pattern ?? parentPattern
);
configs.push(...result);
@@ -328,6 +398,7 @@ const createNormalizedConfigs = (
};
const createConfigItem = (
legacy: boolean,
screen: string,
routeNames: string[],
pattern: string,
@@ -342,6 +413,12 @@ const createConfigItem = (
`^(${pattern
.split('/')
.map((it) => {
if (legacy && it === '*') {
throw new Error(
"Please update your config to the new format to use wildcard pattern ('*'). https://reactnavigation.org/docs/configuring-links/#updating-config"
);
}
if (it.startsWith(':')) {
return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
}

View File

@@ -496,14 +496,14 @@ export type TypedNavigator<
};
export type PathConfig = {
[routeName: string]:
| string
| {
path?: string;
exact?: boolean;
parse?: Record<string, (value: string) => any>;
stringify?: Record<string, (value: any) => string>;
screens?: PathConfig;
initialRouteName?: string;
};
path?: string;
exact?: boolean;
parse?: Record<string, (value: string) => any>;
stringify?: Record<string, (value: any) => string>;
screens?: PathConfigMap;
initialRouteName?: string;
};
export type PathConfigMap = {
[routeName: string]: string | PathConfig;
};

View File

@@ -63,17 +63,19 @@ it('integrates with the history API', () => {
const linking = {
prefixes: [],
config: {
Home: {
path: '',
initialRouteName: 'Feed',
screens: {
Profile: ':user',
Settings: 'edit',
Updates: 'updates',
Feed: 'feed',
screens: {
Home: {
path: '',
initialRouteName: 'Feed',
screens: {
Profile: ':user',
Settings: 'edit',
Updates: 'updates',
Feed: 'feed',
},
},
Chat: 'chat',
},
Chat: 'chat',
},
};

View File

@@ -57,14 +57,16 @@ it('renders correct state with location', () => {
linking={{
prefixes: [],
config: {
Home: {
initialRouteName: 'Profile',
screens: {
Settings: {
path: ':user/edit',
},
Updates: {
path: ':user/updates',
screens: {
Home: {
initialRouteName: 'Profile',
screens: {
Settings: {
path: ':user/edit',
},
Updates: {
path: ':user/updates',
},
},
},
},

View File

@@ -1,7 +1,7 @@
import type {
getStateFromPath as getStateFromPathDefault,
getPathFromState as getPathFromStateDefault,
PathConfig,
PathConfigMap,
} from '@react-navigation/core';
export type Theme = {
@@ -40,7 +40,7 @@ export type LinkingOptions = {
* }
* ```
*/
config?: PathConfig;
config?: { initialRouteName?: string; screens: PathConfigMap };
/**
* Custom function to parse the URL to a valid navigation state (advanced).
* Only applicable on Web.