feat: make deep link handling more flexible

This adds ability to specify a custom config to control how to convert between state and path.

Example:

```js
{
  Chat: {
    path: 'chat/:author/:id',
    parse: { id: Number }
  }
}
```

The above config can parse a path matching the provided pattern: `chat/jane/42` to a valid state:

```js
{
  routes: [
    {
      name: 'Chat',
      params: { author: 'jane', id: 42 },
    },
  ],
}
```

This makes it much easier to control the parsing without having to specify a custom function.
This commit is contained in:
satyajit.happy
2019-09-13 15:38:04 +02:00
committed by Satyajit Sahoo
parent 17045f5b6d
commit 849d952703
8 changed files with 342 additions and 48 deletions

View File

@@ -31,7 +31,7 @@
"@types/jest": "^24.0.13",
"codecov": "^3.5.0",
"commitlint": "^8.0.0",
"core-js": "^3.1.4",
"core-js": "^3.2.1",
"eslint": "^5.16.0",
"eslint-config-satya164": "^2.4.1",
"husky": "^2.4.0",

View File

@@ -29,6 +29,8 @@
"clean": "del lib"
},
"dependencies": {
"escape-string-regexp": "^2.0.0",
"query-string": "^6.8.3",
"shortid": "^2.2.14",
"use-subscription": "^1.0.0"
},

View File

@@ -1,6 +1,6 @@
import getPathFromState from '../getPathFromState';
it('converts path string to initial state', () => {
it('converts state to path string', () => {
expect(
getPathFromState({
routes: [
@@ -12,11 +12,12 @@ it('converts path string to initial state', () => {
{ name: 'boo' },
{
name: 'bar',
params: { fruit: 'apple' },
state: {
routes: [
{
name: 'baz qux',
params: { author: 'jane & co', valid: true },
params: { author: 'jane', valid: true },
},
],
},
@@ -26,9 +27,50 @@ it('converts path string to initial state', () => {
},
],
})
).toMatchInlineSnapshot(
`"/foo/bar/baz%20qux?author=%22jane%20%26%20co%22&valid=true"`
);
).toMatchInlineSnapshot(`"/foo/bar/baz%20qux?author=jane&valid=true"`);
});
it('converts state to path string with config', () => {
expect(
getPathFromState(
{
routes: [
{
name: 'Foo',
state: {
index: 1,
routes: [
{ name: 'boo' },
{
name: 'Bar',
params: { fruit: 'apple', type: 'sweet', avaliable: false },
state: {
routes: [
{
name: 'Baz',
params: { author: 'Jane', valid: true, id: 10 },
},
],
},
},
],
},
},
],
},
{
Foo: 'few',
Bar: 'bar/:type/:fruit',
Baz: {
path: 'baz/:author',
stringify: {
author: author => author.toLowerCase(),
id: id => `x${id}`,
},
},
}
)
).toMatchInlineSnapshot(`"/few/bar/sweet/apple/baz/jane?id=x10&valid=true"`);
});
it('handles route without param', () => {

View File

@@ -2,9 +2,7 @@ import getStateFromPath from '../getStateFromPath';
it('converts path string to initial state', () => {
expect(
getStateFromPath(
'foo/bar/baz%20qux?author=%22jane%20%26%20co%22&valid=true'
)
getStateFromPath('foo/bar/baz%20qux?author=jane%20%26%20co&valid=true')
).toEqual({
routes: [
{
@@ -17,7 +15,55 @@ it('converts path string to initial state', () => {
routes: [
{
name: 'baz qux',
params: { author: 'jane & co', valid: true },
params: { author: 'jane & co', valid: 'true' },
},
],
},
},
],
},
},
],
});
});
it('converts path string to initial state with config', () => {
expect(
getStateFromPath(
'/few/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true',
{
Foo: 'few',
Bar: 'bar/:type/:fruit',
Baz: {
path: 'baz/:author',
parse: {
author: (author: string) =>
author.replace(/^\w/, c => c.toUpperCase()),
count: Number,
valid: Boolean,
},
},
}
)
).toEqual({
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Bar',
params: { fruit: 'apple', type: 'sweet' },
state: {
routes: [
{
name: 'Baz',
params: {
author: 'Jane',
count: 10,
answer: '42',
valid: true,
},
},
],
},
@@ -38,7 +84,7 @@ it('handles leading slash when converting', () => {
routes: [
{
name: 'bar',
params: { count: 42 },
params: { count: '42' },
},
],
},
@@ -56,7 +102,7 @@ it('handles ending slash when converting', () => {
routes: [
{
name: 'bar',
params: { count: 42 },
params: { count: '42' },
},
],
},

View File

@@ -1,14 +1,45 @@
import queryString from 'query-string';
import { NavigationState, PartialState, Route } from './types';
type State = NavigationState | Omit<PartialState<NavigationState>, 'stale'>;
type StringifyConfig = { [key: string]: (value: any) => string };
type Options = {
[routeName: string]: string | { path: string; stringify?: StringifyConfig };
};
/**
* Utility to serialize a navigation state object to a path string.
*
* Example:
* ```js
* getPathFromState(
* {
* routes: [
* {
* name: 'Chat',
* params: { author: 'Jane', id: 42 },
* },
* ],
* },
* {
* Chat: {
* path: 'chat/:author/:id',
* stringify: { author: author => author.toLowerCase() }
* }
* }
* )
* ```
*
* @param state Navigation state to serialize.
* @param options Extra options to fine-tune how to serialize the path.
* @returns Path representing the state, e.g. /foo/bar?count=42.
*/
export default function getPathFromState(state: State): string {
export default function getPathFromState(
state: State,
options: Options = {}
): string {
let path = '/';
let current: State | undefined = state;
@@ -19,24 +50,51 @@ export default function getPathFromState(state: State): string {
state?: State | undefined;
};
path += encodeURIComponent(route.name);
const config =
options[route.name] !== undefined
? (options[route.name] as { stringify?: StringifyConfig }).stringify
: undefined;
const params = route.params
? // Stringify all of the param values before we use them
Object.entries(route.params).reduce<{
[key: string]: string;
}>((acc, [key, value]) => {
acc[key] = config && config[key] ? config[key](value) : String(value);
return acc;
}, {})
: undefined;
if (options[route.name] !== undefined) {
const pattern =
typeof options[route.name] === 'string'
? (options[route.name] as string)
: (options[route.name] as { path: string }).path;
path += pattern
.split('/')
.map(p => {
const name = p.replace(/^:/, '');
// If the path has a pattern for a param, put the param in the path
if (params && name in params && p.startsWith(':')) {
const value = params[name];
// Remove the used value from the params object since we'll use the rest for query string
delete params[name];
return encodeURIComponent(value);
}
return encodeURIComponent(p);
})
.join('/');
} else {
path += encodeURIComponent(route.name);
}
if (route.state) {
path += '/';
} else if (route.params) {
const query = [];
for (const param in route.params) {
const value = (route.params as { [key: string]: any })[param];
query.push(
`${encodeURIComponent(param)}=${encodeURIComponent(
JSON.stringify(value)
)}`
);
}
path += `?${query.join('&')}`;
} else if (params) {
path += `?${queryString.stringify(params)}`;
}
current = route.state;

View File

@@ -1,50 +1,149 @@
import escape from 'escape-string-regexp';
import queryString from 'query-string';
import { NavigationState, PartialState } from './types';
type ParseConfig = { [key: string]: (value: string) => any };
type Options = {
[routeName: string]: string | { path: string; parse?: ParseConfig };
};
/**
* Utility to parse a path string to initial state object accepted by the container.
* This is useful for deep linking when we need to handle the incoming URL.
*
* Example:
* ```js
* getStateFromPath(
* '/chat/jane/42',
* {
* Chat: {
* path: 'chat/:author/:id',
* parse: { id: Number }
* }
* }
* )
* ```
* @param path Path string to parse and convert, e.g. /foo/bar?count=42.
* @param options Extra options to fine-tune how to parse the path.
*/
export default function getStateFromPath(
path: string
path: string,
options: Options = {}
): PartialState<NavigationState> | undefined {
const parts = path.split('?');
const segments = parts[0].split('/').filter(Boolean);
const query = parts[1] ? parts[1].split('&') : undefined;
// Create a normalized config array which will be easier to use
const routeConfig = Object.keys(options).map(key => {
const pattern =
typeof options[key] === 'string'
? (options[key] as string)
: (options[key] as { path: string }).path;
// Create a regex from the provided path pattern
// With the pattern, we can match segements containing params and extract them
const match = new RegExp(
'^' + escape(pattern).replace(/:[a-z0-9]+/gi, '([^/]+)') + '/?'
);
return {
match,
pattern,
routeName: key,
// @ts-ignore
parse: options[key].parse,
};
});
let result: PartialState<NavigationState> | undefined;
let current: PartialState<NavigationState> | undefined;
while (segments.length) {
let remaining = path
.replace(/[/]+/, '/') // Replace multiple slash (//) with single ones
.replace(/^\//, '') // Remove extra leading slash
.replace(/\?.*/, ''); // Remove query params which we will handle later
while (remaining) {
let routeName;
let params;
// Go through all configs, and see if the next path segment matches our regex
for (const config of routeConfig) {
const match = remaining.match(config.match);
// If our regex matches, we need to extract params from the path
if (match) {
routeName = config.routeName;
const paramPatterns = config.pattern
.split('/')
.filter(p => p.startsWith(':'));
if (paramPatterns.length) {
params = paramPatterns.reduce<{ [key: string]: any }>((acc, p, i) => {
const key = p.replace(/^:/, '');
const value = match[i + 1]; // The param segments start from index 1 in the regex match result
acc[key] =
config.parse && config.parse[key]
? config.parse[key](value)
: value;
return acc;
}, {});
}
// Remove the matched segment from the remaining path
remaining = remaining.replace(match[0], '');
break;
}
}
// If we hadn't matched any segments earlier, use the path as route name
if (routeName === undefined) {
const segments = remaining.split('/');
routeName = decodeURIComponent(segments[0]);
segments.shift();
remaining = segments.join('/');
}
const state = {
routes: [{ name: decodeURIComponent(segments[0]) }],
routes: [{ name: routeName, params }],
};
if (current) {
// The state should be nested inside the route we parsed before
current.routes[0].state = state;
} else {
result = state;
}
current = state;
segments.shift();
}
if (current == null || result == null) {
return undefined;
}
const query = path.split('?')[1];
if (query) {
const params = query.reduce<{ [key: string]: any }>((acc, curr) => {
const [key, value] = curr.split('=');
const route = current.routes[0];
acc[decodeURIComponent(key)] = JSON.parse(decodeURIComponent(value));
const params = queryString.parse(query);
const config = options[route.name]
? (options[route.name] as { parse?: ParseConfig }).parse
: undefined;
return acc;
}, {});
if (config) {
Object.keys(params).forEach(name => {
if (config[name] && typeof params[name] === 'string') {
params[name] = config[name](params[name] as string);
}
});
}
current.routes[0].params = params;
route.params = { ...route.params, ...params };
}
return result;

View File

@@ -7,6 +7,12 @@ import {
PartialState,
} from '@react-navigation/core';
type Config = {
[routeName: string]:
| string
| { path: string; parse?: { [key: string]: (value: string) => any } };
};
type Options = {
/**
* The prefixes are stripped from the URL before parsing them.
@@ -14,27 +20,44 @@ type Options = {
*/
prefixes: string[];
/**
* Custom function to parse the URL object to a valid navigation state.
* Config to fine-tune how to parse the path.
*
* Example:
* ```js
* {
* Chat: {
* path: 'chat/:author/:id',
* parse: { id: Number }
* }
* }
* ```
*/
config?: Config;
/**
* Custom function to parse the URL object to a valid navigation state (advanced).
*/
getStateFromPath?: (
path: string
path: string,
options?: Config
) => PartialState<NavigationState> | undefined;
};
export default function useLinking(
ref: React.RefObject<NavigationContainerRef>,
{ prefixes, getStateFromPath = getStateFromPathDefault }: Options
{ prefixes, config, getStateFromPath = getStateFromPathDefault }: Options
) {
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
const prefixesRef = React.useRef(prefixes);
const configRef = React.useRef(config);
const getStateFromPathRef = React.useRef(getStateFromPath);
React.useEffect(() => {
prefixesRef.current = prefixes;
configRef.current = config;
getStateFromPathRef.current = getStateFromPath;
}, [getStateFromPath, prefixes]);
}, [config, getStateFromPath, prefixes]);
const extractPathFromURL = React.useCallback((url: string) => {
for (const prefix of prefixesRef.current) {
@@ -51,7 +74,7 @@ export default function useLinking(
const path = url ? extractPathFromURL(url) : null;
if (path) {
return getStateFromPathRef.current(path);
return getStateFromPathRef.current(path, configRef.current);
} else {
return undefined;
}
@@ -63,7 +86,7 @@ export default function useLinking(
const navigation = ref.current;
if (navigation && path) {
const state = getStateFromPathRef.current(path);
const state = getStateFromPathRef.current(path, configRef.current);
if (state) {
navigation.resetRoot(state);

View File

@@ -4941,7 +4941,7 @@ core-js@^2.2.2, core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.0, core-js@^2.6.5:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
core-js@^3.0.0, core-js@^3.1.4:
core-js@^3.0.0, core-js@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09"
integrity sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw==
@@ -5960,6 +5960,11 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
escape-string-regexp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344"
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
escodegen@^1.9.1:
version "1.12.0"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.12.0.tgz#f763daf840af172bb3a2b6dd7219c0e17f7ff541"
@@ -12593,6 +12598,15 @@ qs@^6.5.0:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.8.0.tgz#87b763f0d37ca54200334cd57bb2ef8f68a1d081"
integrity sha512-tPSkj8y92PfZVbinY1n84i1Qdx75lZjMQYx9WZhnkofyxzw2r7Ho39G3/aEvSUdebxpnnM4LZJCtvE/Aq3+s9w==
query-string@^6.8.3:
version "6.8.3"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.8.3.tgz#fd9fb7ffb068b79062b43383685611ee47777d4b"
integrity sha512-llcxWccnyaWlODe7A9hRjkvdCKamEKTh+wH8ITdTc3OhchaqUZteiSCX/2ablWHVrkVIe04dntnaZJ7BdyW0lQ==
dependencies:
decode-uri-component "^0.2.0"
split-on-first "^1.0.0"
strict-uri-encode "^2.0.0"
querystring-es3@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@@ -14225,6 +14239,11 @@ spdy@^4.0.0:
select-hose "^2.0.0"
spdy-transport "^3.0.0"
split-on-first@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@@ -14381,6 +14400,11 @@ stream-shift@^1.0.0:
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY=
string-length@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"