diff --git a/example/src/index.tsx b/example/src/index.tsx index fc5fee36..1838d62a 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -26,7 +26,7 @@ import { NavigationContainer, DefaultTheme, DarkTheme, - PathConfig, + PathConfigMap, NavigationContainerRef, } from '@react-navigation/native'; import { @@ -225,47 +225,49 @@ export default function App() { // The first segment of the link is the the scheme + host (returned by `Linking.makeUrl`) prefixes: LinkingPrefixes, config: { - Root: { - path: '', - initialRouteName: 'Home', - screens: Object.keys(SCREENS).reduce( - (acc, name) => { - // Convert screen names such as SimpleStack to kebab case (simple-stack) - const path = name - .replace(/([A-Z]+)/g, '-$1') - .replace(/^-/, '') - .toLowerCase(); + screens: { + Root: { + path: '', + initialRouteName: 'Home', + screens: Object.keys(SCREENS).reduce( + (acc, name) => { + // Convert screen names such as SimpleStack to kebab case (simple-stack) + const path = name + .replace(/([A-Z]+)/g, '-$1') + .replace(/^-/, '') + .toLowerCase(); - acc[name] = { - path, - screens: { - Article: { - path: 'article/:author?', - parse: { - author: (author) => - author.charAt(0).toUpperCase() + - author.slice(1).replace(/-/g, ' '), - }, - stringify: { - author: (author: string) => - author.toLowerCase().replace(/\s/g, '-'), + acc[name] = { + path, + screens: { + Article: { + path: 'article/:author?', + parse: { + author: (author) => + author.charAt(0).toUpperCase() + + author.slice(1).replace(/-/g, ' '), + }, + stringify: { + author: (author: string) => + author.toLowerCase().replace(/\s/g, '-'), + }, }, + Albums: 'music', + Chat: 'chat', + Contacts: 'people', + NewsFeed: 'feed', + Dialog: 'dialog', }, - Albums: 'music', - Chat: 'chat', - Contacts: 'people', - NewsFeed: 'feed', - Dialog: 'dialog', - }, - }; + }; - return acc; - }, - { - Home: '', - NotFound: '*', - } - ), + return acc; + }, + { + Home: '', + NotFound: '*', + } + ), + }, }, }, }} diff --git a/packages/core/src/__tests__/getPathFromState.test.tsx b/packages/core/src/__tests__/getPathFromState.test.tsx index 88f88766..d33df593 100644 --- a/packages/core/src/__tests__/getPathFromState.test.tsx +++ b/packages/core/src/__tests__/getPathFromState.test.tsx @@ -1,6 +1,9 @@ +import type { NavigationState, PartialState } from '@react-navigation/routers'; import getPathFromState from '../getPathFromState'; import getStateFromPath from '../getStateFromPath'; +type State = PartialState; + it('converts state to path string', () => { const state = { routes: [ @@ -31,10 +34,72 @@ it('converts state to path string', () => { const path = '/foo/bar/baz%20qux?author=jane&valid=true'; expect(getPathFromState(state)).toBe(path); - expect(getPathFromState(getStateFromPath(path))).toBe(path); + expect(getPathFromState(getStateFromPath(path) as State)).toBe(path); }); it('converts state to path string with config', () => { + const path = '/few/bar/sweet/apple/baz/jane?id=x10&valid=true'; + const config = { + screens: { + Foo: { + path: 'few', + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + id: (id: string) => Number(id.replace(/^x/, '')), + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + id: (id: number) => `x${id}`, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + 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 }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('converts state to path string with config (legacy)', () => { const path = '/few/bar/sweet/apple/baz/jane?id=x10&valid=true'; const config = { Foo: 'few', @@ -80,8 +145,12 @@ it('converts state to path string with config', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles route without param', () => { @@ -98,7 +167,7 @@ it('handles route without param', () => { }; expect(getPathFromState(state)).toBe(path); - expect(getPathFromState(getStateFromPath(path))).toBe(path); + expect(getPathFromState(getStateFromPath(path) as State)).toBe(path); }); it("doesn't add query param for empty params", () => { @@ -113,10 +182,89 @@ it("doesn't add query param for empty params", () => { }; expect(getPathFromState(state)).toBe(path); - expect(getPathFromState(getStateFromPath(path))).toBe(path); + expect(getPathFromState(getStateFromPath(path) as State)).toBe(path); }); it('handles state with config with nested screens', () => { + const path = + '/foo/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + id: (id: number) => `x${id}`, + unknown: (_: unknown) => 'x', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: '10', + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles state with config with nested screens (legacy)', () => { const path = '/foo/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; const config = { @@ -179,11 +327,94 @@ it('handles state with config with nested screens', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles state with config with nested screens and exact', () => { + const path = '/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + exact: true, + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + id: (id: number) => `x${id}`, + unknown: (_: unknown) => 'x', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: '10', + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles state with config with nested screens and exact (legacy)', () => { const path = '/foe/bar/sweet/apple/baz/jane?answer=42&count=10&valid=true'; const config = { Foo: { @@ -248,11 +479,80 @@ it('handles state with config with nested screens and exact', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles state with config with nested screens and unused configs', () => { + const path = '/foo/foe/baz/jane?answer=42&count=10&valid=true'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + unknown: (_: unknown) => 'x', + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles state with config with nested screens and unused configs (legacy)', () => { const path = '/foo/foe/baz/jane?answer=42&count=10&valid=true'; const config = { Foo: { @@ -305,11 +605,81 @@ it('handles state with config with nested screens and unused configs', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles state with config with nested screens and unused configs with exact', () => { + const path = '/foe/baz/jane?answer=42&count=10&valid=true'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + exact: true, + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + unknown: (_: unknown) => 'x', + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles state with config with nested screens and unused configs with exact (legacy)', () => { const path = '/foe/baz/jane?answer=42&count=10&valid=true'; const config = { Foo: { @@ -365,11 +735,95 @@ it('handles state with config with nested screens and unused configs with exact' ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles nested object with stringify in it', () => { + const path = '/bar/sweet/apple/foo/bis/jane?answer=42&count=10&valid=true'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + Baz: { + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles nested object with stringify in it (legacy)', () => { const path = '/bar/sweet/apple/foo/bis/jane?answer=42&count=10&valid=true'; const config = { Foo: { @@ -437,11 +891,97 @@ it('handles nested object with stringify in it', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles nested object with stringify in it with exact', () => { + const path = '/bis/jane?answer=42&count=10&valid=true'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + Baz: { + path: 'baz', + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + exact: true, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles nested object with stringify in it with exact (legacy)', () => { const path = '/bar/sweet/apple/foo/bis/jane?answer=42&count=10&valid=true'; const config = { Foo: { @@ -511,21 +1051,27 @@ it('handles nested object with stringify in it with exact', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles nested object for second route depth', () => { const path = '/foo/bar/baz'; const config = { - Foo: { - path: 'foo', - screens: { - Foe: 'foe', - Bar: { - path: 'bar', - screens: { - Baz: 'baz', + screens: { + Foo: { + path: 'foo', + screens: { + Foe: 'foe', + Bar: { + path: 'bar', + screens: { + Baz: 'baz', + }, }, }, }, @@ -551,22 +1097,26 @@ it('handles nested object for second route depth', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles nested object for second route depth with exact', () => { const path = '/baz'; const config = { - Foo: { - path: 'foo', - screens: { - Foe: 'foe', - Bar: { - path: 'bar', - screens: { - Baz: { - path: 'baz', - exact: true, + screens: { + Foo: { + path: 'foo', + screens: { + Foe: 'foe', + Bar: { + path: 'bar', + screens: { + Baz: { + path: 'baz', + exact: true, + }, }, }, }, @@ -593,26 +1143,30 @@ it('handles nested object for second route depth with exact', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles nested object for second route depth and path and stringify in roots', () => { const path = '/foo/dathomir/bar/42/baz'; const config = { - Foo: { - path: 'foo/:planet', - stringify: { - id: (id: number) => `planet=${id}`, - }, - screens: { - Foe: 'foe', - Bar: { - path: 'bar/:id', - parse: { - id: Number, - }, - screens: { - Baz: 'baz', + screens: { + Foo: { + path: 'foo/:planet', + stringify: { + id: (id: number) => `planet=${id}`, + }, + screens: { + Foe: 'foe', + Bar: { + path: 'bar/:id', + parse: { + id: Number, + }, + screens: { + Baz: 'baz', + }, }, }, }, @@ -639,31 +1193,35 @@ it('handles nested object for second route depth and path and stringify in roots }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles nested object for second route depth and path and stringify in roots with exact', () => { const path = '/baz'; const config = { - Foo: { - path: 'foo/:id', - stringify: { - id: (id: number) => `id=${id}`, - }, - screens: { - Foe: 'foe', - Bar: { - path: 'bar/:id', - stringify: { - id: (id: number) => `id=${id}`, - }, - parse: { - id: Number, - }, - screens: { - Baz: { - path: 'baz', - exact: true, + screens: { + Foo: { + path: 'foo/:id', + stringify: { + id: (id: number) => `id=${id}`, + }, + screens: { + Foe: 'foe', + Bar: { + path: 'bar/:id', + stringify: { + id: (id: number) => `id=${id}`, + }, + parse: { + id: Number, + }, + screens: { + Baz: { + path: 'baz', + exact: true, + }, }, }, }, @@ -690,19 +1248,23 @@ it('handles nested object for second route depth and path and stringify in roots }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('ignores empty string paths', () => { const path = '/bar'; const config = { - Foo: { - path: '', - screens: { - Foe: 'foe', + screens: { + Foo: { + path: '', + screens: { + Foe: 'foe', + }, }, + Bar: 'bar', }, - Bar: 'bar', }; const state = { @@ -717,22 +1279,26 @@ it('ignores empty string paths', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('keeps query params if path is empty', () => { const path = '/?foo=42'; const config = { - Foo: { - screens: { - Foe: 'foe', - Bar: { - screens: { - Qux: { - path: '', - parse: { foo: Number }, + screens: { + Foo: { + screens: { + Foe: 'foe', + Bar: { + screens: { + Qux: { + path: '', + parse: { foo: Number }, + }, + Baz: 'baz', }, - Baz: 'baz', }, }, }, @@ -758,12 +1324,56 @@ it('keeps query params if path is empty', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toEqual( - path - ); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toEqual(path); }); it('cuts nested configs too', () => { + const path = '/foo/baz'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Bar: { + path: '', + screens: { + Baz: { + path: 'baz', + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Bar', + state: { + routes: [{ name: 'Baz' }], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('cuts nested configs too (legacy)', () => { const path = '/foo/baz'; const config = { Foo: { @@ -795,25 +1405,33 @@ it('cuts nested configs too', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('cuts nested configs too with exact', () => { const path = '/baz'; const config = { - Foo: { - path: 'foo', - screens: { - Bar: { - path: '', - exact: true, + screens: { + Foo: { + path: 'foo', + screens: { + Bar: { + path: '', + exact: true, + screens: { + Baz: { + path: 'baz', + }, + }, + }, }, }, }, - Baz: { - path: 'baz', - }, }; const state = { @@ -835,19 +1453,23 @@ it('cuts nested configs too with exact', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles empty path at the end', () => { const path = '/foo/bar'; const config = { - Foo: { - path: 'foo', - screens: { - Bar: 'bar', + screens: { + Foo: { + path: 'foo', + screens: { + Bar: 'bar', + }, }, + Baz: { path: '' }, }, - Baz: { path: '' }, }; const state = { @@ -869,17 +1491,21 @@ it('handles empty path at the end', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('returns "/" for empty path', () => { const path = '/'; const config = { - Foo: { - path: '', - screens: { - Bar: '', + screens: { + Foo: { + path: '', + screens: { + Bar: '', + }, }, }, }; @@ -900,10 +1526,42 @@ it('returns "/" for empty path', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('parses no path specified', () => { + const path = '/bar'; + const config = { + screens: { + Foo: { + screens: { + Foe: {}, + Bar: 'bar', + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [{ name: 'Bar' }], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('parses no path specified (legacy)', () => { const path = '/Foo/bar'; const config = { Foo: { @@ -925,8 +1583,12 @@ it('parses no path specified', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('parses no path specified in nested config', () => { @@ -959,11 +1621,94 @@ it('parses no path specified in nested config', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('strips undefined query params', () => { + const path = '/bar/sweet/apple/foo/bis/jane?count=10&valid=true'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + Baz: { + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('strips undefined query params (legacy)', () => { const path = '/bar/sweet/apple/foo/bis/jane?count=10&valid=true'; const config = { Foo: { @@ -1030,11 +1775,95 @@ it('strips undefined query params', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('strips undefined query params with exact', () => { + const path = '/bis/jane?count=10&valid=true'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + Baz: { + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + exact: true, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('strips undefined query params with exact (legacy)', () => { const path = '/bar/sweet/apple/foo/bis/jane?count=10&valid=true'; const config = { Foo: { @@ -1103,11 +1932,92 @@ it('strips undefined query params with exact', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles stripping all query params', () => { + const path = '/bar/sweet/apple/foo/bis/jane'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + Baz: { + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles stripping all query params (legacy)', () => { const path = '/bar/sweet/apple/foo/bis/jane'; const config = { Foo: { @@ -1172,11 +2082,94 @@ it('handles stripping all query params', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('handles stripping all query params with exact', () => { + const path = '/bis/jane'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + }, + Baz: { + path: 'baz', + screens: { + Bos: 'bos', + Bis: { + path: 'bis/:author', + exact: true, + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); +}); + +it('handles stripping all query params with exact (legacy)', () => { const path = '/bar/sweet/apple/foo/bis/jane'; const config = { Foo: { @@ -1243,14 +2236,20 @@ it('handles stripping all query params with exact', () => { ], }; + // @ts-expect-error expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + // @ts-expect-error + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('replaces undefined query params', () => { const path = '/bar/undefined/apple'; const config = { - Bar: 'bar/:type/:fruit', + screens: { + Bar: 'bar/:type/:fruit', + }, }; const state = { @@ -1263,17 +2262,21 @@ it('replaces undefined query params', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('matches wildcard patterns at root', () => { const path = '/test/bar/42/whatever'; const config = { - 404: '*', - Foo: { - screens: { - Bar: { - path: '/bar/:id/', + screens: { + 404: '*', + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + }, }, }, }, @@ -1284,18 +2287,22 @@ it('matches wildcard patterns at root', () => { }; expect(getPathFromState(state, config)).toBe('/404'); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404'); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe('/404'); }); it('matches wildcard patterns at nested level', () => { const path = '/bar/42/whatever/baz/initt'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar/:id/', - screens: { - 404: '*', + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + 404: '*', + }, }, }, }, @@ -1322,26 +2329,28 @@ it('matches wildcard patterns at nested level', () => { }; expect(getPathFromState(state, config)).toBe('/bar/42/404'); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe( - '/bar/42/404' - ); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe('/bar/42/404'); }); it('matches wildcard patterns at nested level with exact', () => { const path = '/whatever'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar/:id/', - screens: { - 404: { - path: '*', - exact: true, + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + 404: { + path: '*', + exact: true, + }, }, }, + Baz: {}, }, - Baz: {}, }, }, }; @@ -1365,19 +2374,23 @@ it('matches wildcard patterns at nested level with exact', () => { }; expect(getPathFromState(state, config)).toBe('/404'); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404'); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe('/404'); }); it('tries to match wildcard patterns at the end', () => { const path = '/bar/42/test'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar/:id/', - screens: { - 404: '*', - Test: 'test', + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + 404: '*', + Test: 'test', + }, }, }, }, @@ -1404,11 +2417,47 @@ it('tries to match wildcard patterns at the end', () => { }; expect(getPathFromState(state, config)).toBe(path); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe(path); }); it('uses nearest parent wildcard match for unmatched paths', () => { const path = '/bar/42/baz/test'; + const config = { + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + Baz: 'baz', + }, + }, + 404: '*', + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [{ name: '404' }], + }, + }, + ], + }; + + expect(getPathFromState(state, config)).toBe('/404'); + expect( + getPathFromState(getStateFromPath(path, config) as State, config) + ).toBe('/404'); +}); + +it('throws if wildcard is specified with legacy config', () => { const config = { Foo: { screens: { @@ -1434,6 +2483,101 @@ it('uses nearest parent wildcard match for unmatched paths', () => { ], }; - expect(getPathFromState(state, config)).toBe('/404'); - expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404'); + // @ts-expect-error + expect(() => getPathFromState(state, config)).toThrow( + "Please update your config to the new format to use wildcard pattern ('*')" + ); +}); + +it('supports legacy config', () => { + const path = '/few/bar/sweet/apple/baz/jane?id=x10&valid=true'; + const config = { + Foo: 'few', + Bar: 'bar/:type/:fruit', + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + id: (id: string) => Number(id.replace(/^x/, '')), + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + id: (id: number) => `x${id}`, + }, + }, + }; + + const state = { + 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 }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + // @ts-expect-error + expect(getPathFromState(state, config)).toBe(path); +}); + +it("throws when using 'initialRouteName' or 'screens' with legacy config", () => { + expect(() => + getPathFromState( + { routes: [] }, + { + initialRouteName: 'foo', + // @ts-expect-error + Foo: 'foo', + Bar: 'bar/:type/:fruit', + } + ) + ).toThrow('Found invalid keys in the configuration object.'); + + expect(() => + getPathFromState( + { routes: [] }, + { + screens: { + Test: 'test', + }, + // @ts-expect-error + Foo: 'foo', + Bar: 'bar/:type/:fruit', + } + ) + ).toThrow('Found invalid keys in the configuration object.'); + + expect(() => + getPathFromState( + { routes: [] }, + { + initialRouteName: 'foo', + screens: { + Test: 'test', + }, + // @ts-expect-error + Foo: 'foo', + Bar: 'bar/:type/:fruit', + } + ) + ).toThrow('Found invalid keys in the configuration object.'); }); diff --git a/packages/core/src/__tests__/getStateFromPath.test.tsx b/packages/core/src/__tests__/getStateFromPath.test.tsx index 5e256b8d..08afa5b9 100644 --- a/packages/core/src/__tests__/getStateFromPath.test.tsx +++ b/packages/core/src/__tests__/getStateFromPath.test.tsx @@ -35,6 +35,70 @@ it('converts path string to initial state', () => { }); it('converts path string to initial state with config', () => { + const path = '/foo/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + 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, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); + +it('converts path string to initial state with config (legacy)', () => { const path = '/foo/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; const config = { Foo: 'foo', @@ -82,7 +146,9 @@ it('converts path string to initial state with config', () => { ], }; + // @ts-expect-error expect(getStateFromPath(path, config)).toEqual(state); + // @ts-expect-error expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( state ); @@ -142,6 +208,83 @@ it('handles route without param', () => { }); it('converts path string to initial state with config with nested screens', () => { + const path = '/foe/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + exact: true, + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); + +it('converts path string to initial state with config with nested screens (legacy)', () => { const path = '/foe/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; const config = { Foo: { @@ -204,13 +347,77 @@ it('converts path string to initial state with config with nested screens', () = ], }; + // @ts-expect-error expect(getStateFromPath(path, config)).toEqual(state); + // @ts-expect-error expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( state ); }); it('converts path string to initial state with config with nested screens and unused parse functions', () => { + const path = '/foe/baz/jane?count=10&answer=42&valid=true'; + const config = { + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + exact: true, + screens: { + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + id: Boolean, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Baz', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); + +it('converts path string to initial state with config with nested screens and unused parse functions (legacy)', () => { const path = '/foe/baz/jane?count=10&answer=42&valid=true'; const config = { Foo: { @@ -262,13 +469,106 @@ it('converts path string to initial state with config with nested screens and un ], }; + // @ts-expect-error expect(getStateFromPath(path, config)).toEqual(state); + // @ts-expect-error expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( state ); }); it('handles nested object with unused configs and with parse in it', () => { + const path = '/bar/sweet/apple/foe/bis/jane?count=10&answer=42&valid=true'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + screens: { + Foe: { + path: 'foe', + screens: { + Baz: { + screens: { + Bos: { + path: 'bos', + exact: true, + }, + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Baz', + state: { + routes: [ + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); + +it('handles nested object with unused configs and with parse in it (legacy)', () => { const path = '/bar/sweet/apple/foe/bis/jane?count=10&answer=42&valid=true'; const config = { Foo: { @@ -349,7 +649,9 @@ it('handles nested object with unused configs and with parse in it', () => { ], }; + // @ts-expect-error expect(getStateFromPath(path, config)).toEqual(state); + // @ts-expect-error expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( state ); @@ -358,20 +660,22 @@ it('handles nested object with unused configs and with parse in it', () => { it('handles parse in nested object for second route depth', () => { const path = '/baz'; const config = { - Foo: { - path: 'foo', - screens: { - Foe: { - path: 'foe', - exact: true, - }, - Bar: { - path: 'bar', - exact: true, - screens: { - Baz: { - path: 'baz', - exact: true, + screens: { + Foo: { + path: 'foo', + screens: { + Foe: { + path: 'foe', + exact: true, + }, + Bar: { + path: 'bar', + exact: true, + screens: { + Baz: { + path: 'baz', + exact: true, + }, }, }, }, @@ -406,19 +710,24 @@ it('handles parse in nested object for second route depth', () => { it('handles parse in nested object for second route depth and and path and parse in roots', () => { const path = '/baz'; const config = { - Foo: { - path: 'foo/:id', - parse: { - id: Number, - }, - stringify: { - id: (id: number) => `id=${id}`, - }, - screens: { - Foe: 'foe', - Bar: { - screens: { - Baz: 'baz', + screens: { + Foo: { + path: 'foo/:id', + parse: { + id: Number, + }, + stringify: { + id: (id: number) => `id=${id}`, + }, + screens: { + Foe: 'foe', + Bar: { + screens: { + Baz: { + path: 'baz', + exact: true, + }, + }, }, }, }, @@ -449,16 +758,62 @@ it('handles parse in nested object for second route depth and and path and parse ); }); -it('handles initialRouteName', () => { +it('handles initialRouteName at top level', () => { const path = '/baz'; const config = { - Foo: { - initialRouteName: 'Foe', - screens: { - Foe: 'foe', - Bar: { - screens: { - Baz: 'baz', + initialRouteName: 'Boo', + screens: { + Foo: { + screens: { + Foe: 'foe', + Bar: { + screens: { + Baz: 'baz', + }, + }, + }, + }, + }, + }; + + const state = { + index: 1, + routes: [ + { name: 'Boo' }, + { + name: 'Foo', + state: { + routes: [ + { + name: 'Bar', + state: { + routes: [{ name: 'Baz' }], + }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); + +it('handles initialRouteName inside a screen', () => { + const path = '/baz'; + const config = { + screens: { + Foo: { + initialRouteName: 'Foe', + screens: { + Foe: 'foe', + Bar: { + screens: { + Baz: 'baz', + }, }, }, }, @@ -496,15 +851,17 @@ it('handles initialRouteName', () => { it('handles initialRouteName included in path', () => { const path = '/baz'; const config = { - Foo: { - initialRouteName: 'Foe', - screens: { - Foe: { - screens: { - Baz: 'baz', + screens: { + Foo: { + initialRouteName: 'Foe', + screens: { + Foe: { + screens: { + Baz: 'baz', + }, }, + Bar: 'bar', }, - Bar: 'bar', }, }, }; @@ -534,6 +891,100 @@ it('handles initialRouteName included in path', () => { }); it('handles two initialRouteNames', () => { + const path = '/bar/sweet/apple/foe/bis/jane?count=10&answer=42&valid=true'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + screens: { + Foe: { + path: 'foe', + screens: { + Baz: { + initialRouteName: 'Bos', + screens: { + Bos: { + path: 'bos', + exact: true, + }, + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Baz', + state: { + index: 1, + routes: [ + { name: 'Bos' }, + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); + +it('handles two initialRouteNames (legacy)', () => { const path = '/bar/sweet/apple/foe/bis/jane?count=10&answer=42&valid=true'; const config = { Foo: { @@ -616,13 +1067,109 @@ it('handles two initialRouteNames', () => { ], }; + // @ts-expect-error expect(getStateFromPath(path, config)).toEqual(state); + // @ts-expect-error expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( state ); }); it('accepts initialRouteName without config for it', () => { + const path = '/bar/sweet/apple/foe/bis/jane?count=10&answer=42&valid=true'; + const config = { + screens: { + Bar: { + path: 'bar/:type/:fruit', + screens: { + Foo: { + screens: { + Foe: { + path: 'foe', + screens: { + Baz: { + initialRouteName: 'Bas', + screens: { + Bos: { + path: 'bos', + exact: true, + }, + Bis: { + path: 'bis/:author', + stringify: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toLowerCase()), + }, + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const state = { + routes: [ + { + name: 'Bar', + params: { fruit: 'apple', type: 'sweet' }, + state: { + routes: [ + { + name: 'Foo', + state: { + routes: [ + { + name: 'Foe', + state: { + routes: [ + { + name: 'Baz', + state: { + index: 1, + routes: [ + { name: 'Bas' }, + { + name: 'Bis', + params: { + author: 'Jane', + count: 10, + answer: '42', + valid: true, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(getStateFromPath(path, config)).toEqual(state); + expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( + state + ); +}); + +it('accepts initialRouteName without config for it (legacy)', () => { const path = '/bar/sweet/apple/foe/bis/jane?count=10&answer=42&valid=true'; const config = { Foo: { @@ -705,7 +1252,9 @@ it('accepts initialRouteName without config for it', () => { ], }; + // @ts-expect-error expect(getStateFromPath(path, config)).toEqual(state); + // @ts-expect-error expect(getStateFromPath(getPathFromState(state, config), config)).toEqual( state ); @@ -713,12 +1262,14 @@ it('accepts initialRouteName without config for it', () => { it('returns undefined if path is empty and no matching screen is present', () => { const config = { - Foo: { - screens: { - Foe: 'foe', - Bar: { - screens: { - Baz: 'baz', + screens: { + Foo: { + screens: { + Foe: 'foe', + Bar: { + screens: { + Baz: 'baz', + }, }, }, }, @@ -733,13 +1284,15 @@ it('returns undefined if path is empty and no matching screen is present', () => it('returns matching screen if path is empty', () => { const path = ''; const config = { - Foo: { - screens: { - Foe: 'foe', - Bar: { - screens: { - Qux: '', - Baz: 'baz', + screens: { + Foo: { + screens: { + Foe: 'foe', + Bar: { + screens: { + Qux: '', + Baz: 'baz', + }, }, }, }, @@ -773,16 +1326,18 @@ it('returns matching screen if path is empty', () => { it('returns matching screen with params if path is empty', () => { const path = '?foo=42'; const config = { - Foo: { - screens: { - Foe: 'foe', - Bar: { - screens: { - Qux: { - path: '', - parse: { foo: Number }, + screens: { + Foo: { + screens: { + Foe: 'foe', + Bar: { + screens: { + Qux: { + path: '', + parse: { foo: Number }, + }, + Baz: 'baz', }, - Baz: 'baz', }, }, }, @@ -815,15 +1370,17 @@ it('returns matching screen with params if path is empty', () => { it("doesn't match nested screen if path is empty", () => { const config = { - Foo: { - screens: { - Foe: 'foe', - Bar: { - path: 'bar', - screens: { - Qux: { - path: '', - parse: { foo: Number }, + screens: { + Foo: { + screens: { + Foe: 'foe', + Bar: { + path: 'bar', + screens: { + Qux: { + path: '', + parse: { foo: Number }, + }, }, }, }, @@ -840,15 +1397,17 @@ it('chooses more exhaustive pattern', () => { const path = '/foo/5'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, }, }, @@ -885,13 +1444,15 @@ it('handles same paths beginnings', () => { const path = '/foos'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foos', + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foos', + }, }, }, }, @@ -926,15 +1487,17 @@ it('handles same paths beginnings with params', () => { const path = '/foos/5'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foos/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foos/:id', + parse: { + id: Number, + }, }, }, }, @@ -971,22 +1534,24 @@ it('handles not taking path with too many segments', () => { const path = '/foos/5'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foos/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foos/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip', - parse: { - id: Number, - pwd: Number, + Bas: { + path: 'foos/:id/:nip', + parse: { + id: Number, + pwd: Number, + }, }, }, }, @@ -1023,22 +1588,24 @@ it('handles differently ordered params v1', () => { const path = '/foos/5/res/20'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foos/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foos/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/res/:pwd', - parse: { - id: Number, - pwd: Number, + Bas: { + path: 'foos/:id/res/:pwd', + parse: { + id: Number, + pwd: Number, + }, }, }, }, @@ -1075,22 +1642,24 @@ it('handles differently ordered params v2', () => { const path = '/5/20/foos/res'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foos/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foos/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: ':id/:pwd/foos/res', - parse: { - id: Number, - pwd: Number, + Bas: { + path: ':id/:pwd/foos/res', + parse: { + id: Number, + pwd: Number, + }, }, }, }, @@ -1127,22 +1696,24 @@ it('handles differently ordered params v3', () => { const path = '/foos/5/20/res'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foos/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foos/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:pwd/res', - parse: { - id: Number, - pwd: Number, + Bas: { + path: 'foos/:id/:pwd/res', + parse: { + id: Number, + pwd: Number, + }, }, }, }, @@ -1179,22 +1750,24 @@ it('handles differently ordered params v4', () => { const path = '5/foos/res/20'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foos/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foos/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: ':id/foos/res/:pwd', - parse: { - id: Number, - pwd: Number, + Bas: { + path: ':id/foos/res/:pwd', + parse: { + id: Number, + pwd: Number, + }, }, }, }, @@ -1231,22 +1804,24 @@ it('handles simple optional params', () => { const path = '/foos/5'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip?', - parse: { - id: Number, - nip: Number, + Bas: { + path: 'foos/:id/:nip?', + parse: { + id: Number, + nip: Number, + }, }, }, }, @@ -1283,22 +1858,24 @@ it('handle 2 optional params at the end v1', () => { const path = '/foos/5'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip?/:pwd?', - parse: { - id: Number, - nip: Number, + Bas: { + path: 'foos/:id/:nip?/:pwd?', + parse: { + id: Number, + nip: Number, + }, }, }, }, @@ -1335,22 +1912,24 @@ it('handle 2 optional params at the end v2', () => { const path = '/foos/5/10'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip?/:pwd?', - parse: { - id: Number, - nip: Number, + Bas: { + path: 'foos/:id/:nip?/:pwd?', + parse: { + id: Number, + nip: Number, + }, }, }, }, @@ -1387,23 +1966,25 @@ it('handle 2 optional params at the end v3', () => { const path = '/foos/5/10/15'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip?/:pwd?', - parse: { - id: Number, - nip: Number, - pwd: Number, + Bas: { + path: 'foos/:id/:nip?/:pwd?', + parse: { + id: Number, + nip: Number, + pwd: Number, + }, }, }, }, @@ -1440,23 +2021,25 @@ it('handle optional params in the middle v1', () => { const path = '/foos/5/10'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip?/:pwd', - parse: { - id: Number, - nip: Number, - pwd: Number, + Bas: { + path: 'foos/:id/:nip?/:pwd', + parse: { + id: Number, + nip: Number, + pwd: Number, + }, }, }, }, @@ -1493,23 +2076,25 @@ it('handle optional params in the middle v2', () => { const path = '/foos/5/10/15'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip?/:pwd', - parse: { - id: Number, - nip: Number, - pwd: Number, + Bas: { + path: 'foos/:id/:nip?/:pwd', + parse: { + id: Number, + nip: Number, + pwd: Number, + }, }, }, }, @@ -1546,24 +2131,26 @@ it('handle optional params in the middle v3', () => { const path = '/foos/5/10/15'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:id/:nip?/:pwd/:smh', - parse: { - id: Number, - nip: Number, - pwd: Number, - smh: Number, + Bas: { + path: 'foos/:id/:nip?/:pwd/:smh', + parse: { + id: Number, + nip: Number, + pwd: Number, + smh: Number, + }, }, }, }, @@ -1600,24 +2187,26 @@ it('handle optional params in the middle v4', () => { const path = '/foos/5/10'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:nip?/:pwd/:smh?/:id', - parse: { - id: Number, - nip: Number, - pwd: Number, - smh: Number, + Bas: { + path: 'foos/:nip?/:pwd/:smh?/:id', + parse: { + id: Number, + nip: Number, + pwd: Number, + smh: Number, + }, }, }, }, @@ -1654,24 +2243,26 @@ it('handle optional params in the middle v5', () => { const path = '/foos/5/10/15'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: 'foos/:nip?/:pwd/:smh?/:id', - parse: { - id: Number, - nip: Number, - pwd: Number, - smh: Number, + Bas: { + path: 'foos/:nip?/:pwd/:smh?/:id', + parse: { + id: Number, + nip: Number, + pwd: Number, + smh: Number, + }, }, }, }, @@ -1708,24 +2299,26 @@ it('handle optional params in the beginning v1', () => { const path = '5/10/foos/15'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: ':nip?/:pwd/foos/:smh?/:id', - parse: { - id: Number, - nip: Number, - pwd: Number, - smh: Number, + Bas: { + path: ':nip?/:pwd/foos/:smh?/:id', + parse: { + id: Number, + nip: Number, + pwd: Number, + smh: Number, + }, }, }, }, @@ -1762,24 +2355,26 @@ it('handle optional params in the beginning v2', () => { const path = '5/10/foos/15'; const config = { - Foe: { - path: '/', - initialRouteName: 'Foo', - screens: { - Foo: 'foo', - Bis: { - path: 'foo/:id', - parse: { - id: Number, + screens: { + Foe: { + path: '/', + initialRouteName: 'Foo', + screens: { + Foo: 'foo', + Bis: { + path: 'foo/:id', + parse: { + id: Number, + }, }, - }, - Bas: { - path: ':nip?/:smh?/:pwd/foos/:id', - parse: { - id: Number, - nip: Number, - pwd: Number, - smh: Number, + Bas: { + path: ':nip?/:smh?/:pwd/foos/:id', + parse: { + id: Number, + nip: Number, + pwd: Number, + smh: Number, + }, }, }, }, @@ -1816,13 +2411,15 @@ it('merges parent patterns if needed', () => { const path = 'foo/42/baz/babel'; const config = { - Foo: { - path: 'foo/:bar', - parse: { - bar: Number, - }, - screens: { - Baz: 'baz/:qux', + screens: { + Foo: { + path: 'foo/:bar', + parse: { + bar: Number, + }, + screens: { + Baz: 'baz/:qux', + }, }, }, }; @@ -1853,10 +2450,12 @@ it('merges parent patterns if needed', () => { it('ignores extra slashes in the pattern', () => { const path = '/bar/42'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar//:id/', + screens: { + Foo: { + screens: { + Bar: { + path: '/bar//:id/', + }, }, }, }, @@ -1887,11 +2486,13 @@ it('ignores extra slashes in the pattern', () => { it('matches wildcard patterns at root', () => { const path = '/test/bar/42/whatever'; const config = { - 404: '*', - Foo: { - screens: { - Bar: { - path: '/bar/:id/', + screens: { + 404: '*', + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + }, }, }, }, @@ -1910,12 +2511,14 @@ it('matches wildcard patterns at root', () => { it('matches wildcard patterns at nested level', () => { const path = '/bar/42/whatever/baz/initt'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar/:id/', - screens: { - 404: '*', + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + 404: '*', + }, }, }, }, @@ -1950,18 +2553,20 @@ it('matches wildcard patterns at nested level', () => { it('matches wildcard patterns at nested level with exact', () => { const path = '/whatever'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar/:id/', - screens: { - 404: { - path: '*', - exact: true, + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + 404: { + path: '*', + exact: true, + }, }, }, + Baz: {}, }, - Baz: {}, }, }, }; @@ -1993,13 +2598,15 @@ it('matches wildcard patterns at nested level with exact', () => { it('tries to match wildcard patterns at the end', () => { const path = '/bar/42/test'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar/:id/', - screens: { - 404: '*', - Test: 'test', + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + 404: '*', + Test: 'test', + }, }, }, }, @@ -2034,15 +2641,17 @@ it('tries to match wildcard patterns at the end', () => { it('uses nearest parent wildcard match for unmatched paths', () => { const path = '/bar/42/baz/test'; const config = { - Foo: { - screens: { - Bar: { - path: '/bar/:id/', - screens: { - Baz: 'baz', + screens: { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + Baz: 'baz', + }, }, + 404: '*', }, - 404: '*', }, }, }; @@ -2063,3 +2672,111 @@ it('uses nearest parent wildcard match for unmatched paths', () => { state ); }); + +it('throws if wildcard is specified with legacy config', () => { + const path = '/bar/42/baz/test'; + const config = { + Foo: { + screens: { + Bar: { + path: '/bar/:id/', + screens: { + Baz: 'baz', + }, + }, + 404: '*', + }, + }, + }; + + // @ts-expect-error + expect(() => getStateFromPath(path, config)).toThrow( + "Please update your config to the new format to use wildcard pattern ('*')" + ); +}); + +it('supports legacy config', () => { + const path = '/foo/bar/sweet/apple/baz/jane?count=10&answer=42&valid=true'; + const config = { + Foo: 'foo', + Bar: 'bar/:type/:fruit', + Baz: { + path: 'baz/:author', + parse: { + author: (author: string) => + author.replace(/^\w/, (c) => c.toUpperCase()), + count: Number, + valid: Boolean, + }, + stringify: { + author: (author: string) => author.toLowerCase(), + }, + }, + }; + + const state = { + 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, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + // @ts-expect-error + expect(getStateFromPath(path, config)).toEqual(state); +}); + +it("throws when using 'initialRouteName' or 'screens' with legacy config", () => { + expect(() => + getStateFromPath('/whatever', { + initialRouteName: 'foo', + // @ts-expect-error + Foo: 'foo', + Bar: 'bar/:type/:fruit', + }) + ).toThrow('Found invalid keys in the configuration object.'); + + expect(() => + getStateFromPath('/whatever', { + screens: { + Test: 'test', + }, + // @ts-expect-error + Foo: 'foo', + Bar: 'bar/:type/:fruit', + }) + ).toThrow('Found invalid keys in the configuration object.'); + + expect(() => + getStateFromPath('/whatever', { + initialRouteName: 'foo', + screens: { + Test: 'test', + }, + // @ts-expect-error + Foo: 'foo', + Bar: 'bar/:type/:fruit', + }) + ).toThrow('Found invalid keys in the configuration object.'); +}); diff --git a/packages/core/src/checkLegacyPathConfig.tsx b/packages/core/src/checkLegacyPathConfig.tsx new file mode 100644 index 00000000..f4f6bbe9 --- /dev/null +++ b/packages/core/src/checkLegacyPathConfig.tsx @@ -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]; +} diff --git a/packages/core/src/getPathFromState.tsx b/packages/core/src/getPathFromState.tsx index 759050b5..9c715887 100644 --- a/packages/core/src/getPathFromState.tsx +++ b/packages/core/src/getPathFromState.tsx @@ -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, 'stale'>; type StringifyConfig = Record 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 = 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 => fromEntries( Object.entries(options).map(([name, c]) => { - const result = createConfigItem(c, pattern); + const result = createConfigItem(legacy, c, pattern); return [name, result]; }) diff --git a/packages/core/src/getStateFromPath.tsx b/packages/core/src/getStateFromPath.tsx index c365da9a..e1e57831 100644 --- a/packages/core/src/getStateFromPath.tsx +++ b/packages/core/src/getStateFromPath.tsx @@ -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 any>; @@ -36,9 +42,11 @@ type ResultState = PartialState & { * 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 & { */ 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 | undefined; let current: PartialState | 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).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).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('?') ? '?' : ''})`; } diff --git a/packages/core/src/types.tsx b/packages/core/src/types.tsx index 5e41864b..1a531de0 100644 --- a/packages/core/src/types.tsx +++ b/packages/core/src/types.tsx @@ -496,14 +496,14 @@ export type TypedNavigator< }; export type PathConfig = { - [routeName: string]: - | string - | { - path?: string; - exact?: boolean; - parse?: Record any>; - stringify?: Record string>; - screens?: PathConfig; - initialRouteName?: string; - }; + path?: string; + exact?: boolean; + parse?: Record any>; + stringify?: Record string>; + screens?: PathConfigMap; + initialRouteName?: string; +}; + +export type PathConfigMap = { + [routeName: string]: string | PathConfig; }; diff --git a/packages/native/src/__tests__/NavigationContainer.test.tsx b/packages/native/src/__tests__/NavigationContainer.test.tsx index 3de4312b..e00c855b 100644 --- a/packages/native/src/__tests__/NavigationContainer.test.tsx +++ b/packages/native/src/__tests__/NavigationContainer.test.tsx @@ -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', }, }; diff --git a/packages/native/src/__tests__/ServerContainer.test.tsx b/packages/native/src/__tests__/ServerContainer.test.tsx index 4c87f4ee..6d4a8760 100644 --- a/packages/native/src/__tests__/ServerContainer.test.tsx +++ b/packages/native/src/__tests__/ServerContainer.test.tsx @@ -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', + }, }, }, }, diff --git a/packages/native/src/types.tsx b/packages/native/src/types.tsx index 3bb23ed5..47ca110f 100644 --- a/packages/native/src/types.tsx +++ b/packages/native/src/types.tsx @@ -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.