Skip to content

Commit 1c7974a

Browse files
Merge pull request #835 from preactjs/iso-nested-routes
Add support for nested route definitions
2 parents 3a179b8 + 912fee3 commit 1c7974a

File tree

4 files changed

+132
-20
lines changed

4 files changed

+132
-20
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,43 @@ You can use the `useRoute` hook to get information of the route you are currentl
104104
**Progressive Hydration:** When the app is hydrated on the client, the route (`Home` or `Profile` in this case) suspends. This causes hydration for that part of the page to be deferred until the route's `import()` is resolved, at which point that part of the page automatically finishes hydrating.
105105

106106
**Seamless Routing:** Switch switching between routes on the client, the Router is aware of asynchronous dependencies in routes. Instead of clearing the current route and showing a loading spinner while waiting for the next route (or its data), the router preserves the current route in-place until the incoming route has finished loading, then they are swapped.
107+
108+
### Nested Routing
109+
110+
Nested routes are supported by using multiple `Router` components. Partially matched routes end with a wildcard `/*` and the remaining value will be past to continue matching with if there are any further routes.
111+
112+
```jsx
113+
import { ErrorBoundary, LocationProvider, Router, Route } from 'preact-iso';
114+
115+
function ProfileA() {
116+
return <h2>A</h2>;
117+
}
118+
119+
function ProfileB() {
120+
return <h2>B</h2>;
121+
}
122+
123+
function Profile() {
124+
return (
125+
<div>
126+
<h1>Profile</h1>
127+
<ErrorBoundary>
128+
<Router>
129+
<Route path="/a" component={ProfileA} />
130+
<Route path="/b" component={ProfileB} />
131+
</Router>
132+
<ErrorBoundary>
133+
</div>
134+
);
135+
}
136+
137+
const App = () => (
138+
<LocationProvider>
139+
<ErrorBoundary>
140+
<Router>
141+
<Route path="/profiles/*" component={Profile} />
142+
</Router>
143+
</ErrorBoundary>
144+
</LocationProvider>
145+
);
146+
```

router.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export const exec = (url, route, matches) => {
4343
// segment match:
4444
if (!m && param == val) continue;
4545
// /foo/* match
46-
if (!m && val && flag == '*') break;
46+
if (!m && val && flag == '*') {
47+
matches.rest = '/' + url.slice(i).map(decodeURIComponent).join('/');
48+
break;
49+
}
4750
// segment mismatch / missing required field:
4851
if (!m || (!val && flag != '?' && flag != '*')) return;
4952
rest = flag == '+' || flag == '*';
@@ -88,7 +91,8 @@ const RESOLVED = Promise.resolve();
8891
export function Router(props) {
8992
const [, update] = useReducer(c => c + 1, 0);
9093

91-
const { url, path, query, wasPush } = useLocation();
94+
const { url, query, wasPush, path } = useLocation();
95+
const { rest = path, params = {} } = useContext(RouteContext);
9296

9397
// Monotonic counter used to check if an un-suspending route is still the current route:
9498
const count = useRef(0);
@@ -114,13 +118,13 @@ export function Router(props) {
114118

115119
let p, d, m;
116120
toChildArray(props.children).some(vnode => {
117-
const matches = exec(path, vnode.props.path, (m = { path, query, params: {} }));
121+
const matches = exec(rest, vnode.props.path, (m = { path: rest, query, params, rest: '' }));
118122
if (matches) return (p = cloneElement(vnode, m));
119123
if (vnode.props.default) d = cloneElement(vnode, m);
120124
});
121125

122126
return h(RouteContext.Provider, { value: m }, p || d);
123-
}, [url]);
127+
}, [rest, params]);
124128

125129
// Reset previous children - if rendering succeeds synchronously, we shouldn't render the previous children.
126130
const p = prev.current;

test/match.test.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,24 @@ describe('match', () => {
2323

2424
it('Param rest segment', () => {
2525
const accurateResult = execPath('/user/foo', '/user/*');
26-
expect(accurateResult).toEqual({ path: '/user/foo', params: {}, query: {} });
26+
expect(accurateResult).toEqual({ path: '/user/foo', params: {}, query: {}, rest: '/foo' });
2727

2828
const inaccurateResult = execPath('/', '/user/:id/*');
2929
expect(inaccurateResult).toEqual(undefined);
3030
});
3131

3232
it('Param route with rest segment', () => {
3333
const accurateResult = execPath('/user/2/foo', '/user/:id/*');
34-
expect(accurateResult).toEqual({ path: '/user/2/foo', params: { id: '2' }, id: '2', query: {} });
34+
expect(accurateResult).toEqual({ path: '/user/2/foo', params: { id: '2' }, id: '2', query: {}, rest: '/foo' });
3535

3636
const accurateResult2 = execPath('/user/2/foo/bar/bob', '/user/:id/*');
37-
expect(accurateResult2).toEqual({ path: '/user/2/foo/bar/bob', params: { id: '2' }, id: '2', query: {} });
37+
expect(accurateResult2).toEqual({
38+
path: '/user/2/foo/bar/bob',
39+
params: { id: '2' },
40+
id: '2',
41+
query: {},
42+
rest: '/foo/bar/bob'
43+
});
3844

3945
const inaccurateResult = execPath('/', '/user/:id/*');
4046
expect(inaccurateResult).toEqual(undefined);

test/router.test.js

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { jest, describe, it, beforeEach, expect } from '@jest/globals';
22
import { h, render } from 'preact';
33
import { html } from 'htm/preact';
4-
import { LocationProvider, Router, useLocation } from '../router.js';
4+
import { LocationProvider, Router, useLocation, Route, useRoute } from '../router.js';
55
import lazy, { ErrorBoundary } from '../lazy.js';
66

77
Object.defineProperty(window, 'scrollTo', { value() {} });
@@ -47,7 +47,7 @@ describe('Router', () => {
4747
);
4848

4949
expect(scratch).toHaveProperty('textContent', 'Home');
50-
expect(Home).toHaveBeenCalledWith({ path: '/', query: {}, params: {} }, expect.anything());
50+
expect(Home).toHaveBeenCalledWith({ path: '/', query: {}, params: {}, rest: '' }, expect.anything());
5151
expect(Profiles).not.toHaveBeenCalled();
5252
expect(Profile).not.toHaveBeenCalled();
5353
expect(Fallback).not.toHaveBeenCalled();
@@ -64,7 +64,7 @@ describe('Router', () => {
6464

6565
expect(scratch).toHaveProperty('textContent', 'Profiles');
6666
expect(Home).not.toHaveBeenCalled();
67-
expect(Profiles).toHaveBeenCalledWith({ path: '/profiles', query: {}, params: {} }, expect.anything());
67+
expect(Profiles).toHaveBeenCalledWith({ path: '/profiles', query: {}, params: {}, rest: '' }, expect.anything());
6868
expect(Profile).not.toHaveBeenCalled();
6969
expect(Fallback).not.toHaveBeenCalled();
7070

@@ -82,7 +82,7 @@ describe('Router', () => {
8282
expect(Home).not.toHaveBeenCalled();
8383
expect(Profiles).not.toHaveBeenCalled();
8484
expect(Profile).toHaveBeenCalledWith(
85-
{ path: '/profiles/bob', query: {}, params: { id: 'bob' }, id: 'bob' },
85+
{ path: '/profiles/bob', query: {}, params: { id: 'bob' }, id: 'bob', rest: '' },
8686
expect.anything()
8787
);
8888
expect(Fallback).not.toHaveBeenCalled();
@@ -102,7 +102,7 @@ describe('Router', () => {
102102
expect(Profiles).not.toHaveBeenCalled();
103103
expect(Profile).not.toHaveBeenCalled();
104104
expect(Fallback).toHaveBeenCalledWith(
105-
{ default: true, path: '/other', query: { a: 'b', c: 'd' }, params: {} },
105+
{ default: true, path: '/other', query: { a: 'b', c: 'd' }, params: {}, rest: '' },
106106
expect.anything()
107107
);
108108

@@ -141,13 +141,13 @@ describe('Router', () => {
141141
);
142142

143143
expect(scratch).toHaveProperty('innerHTML', '');
144-
expect(A).toHaveBeenCalledWith({ path: '/', query: {}, params: {} }, expect.anything());
144+
expect(A).toHaveBeenCalledWith({ path: '/', query: {}, params: {}, rest: '' }, expect.anything());
145145

146146
A.mockClear();
147147
await sleep(10);
148148

149149
expect(scratch).toHaveProperty('innerHTML', '<h1>A</h1><p>hello</p>');
150-
expect(A).toHaveBeenCalledWith({ path: '/', query: {}, params: {} }, expect.anything());
150+
expect(A).toHaveBeenCalledWith({ path: '/', query: {}, params: {}, rest: '' }, expect.anything());
151151

152152
A.mockClear();
153153
loc.route('/b');
@@ -160,14 +160,14 @@ describe('Router', () => {
160160
expect(scratch).toHaveProperty('innerHTML', '<h1>A</h1><p>hello</p>');
161161
// We should never re-invoke <A /> while loading <B /> (that would be a remount of the old route):
162162
expect(A).not.toHaveBeenCalled();
163-
expect(B).toHaveBeenCalledWith({ path: '/b', query: {}, params: {} }, expect.anything());
163+
expect(B).toHaveBeenCalledWith({ path: '/b', query: {}, params: {}, rest: '' }, expect.anything());
164164

165165
B.mockClear();
166166
await sleep(10);
167167

168168
expect(scratch).toHaveProperty('innerHTML', '<h1>B</h1><p>hello</p>');
169169
expect(A).not.toHaveBeenCalled();
170-
expect(B).toHaveBeenCalledWith({ path: '/b', query: {}, params: {} }, expect.anything());
170+
expect(B).toHaveBeenCalledWith({ path: '/b', query: {}, params: {}, rest: '' }, expect.anything());
171171

172172
B.mockClear();
173173
loc.route('/c');
@@ -186,14 +186,14 @@ describe('Router', () => {
186186
expect(scratch).toHaveProperty('innerHTML', '<h1>B</h1><p>hello</p>');
187187
// We should never re-invoke <A /> while loading <B /> (that would be a remount of the old route):
188188
expect(B).not.toHaveBeenCalled();
189-
expect(C).toHaveBeenCalledWith({ path: '/c', query: {}, params: {} }, expect.anything());
189+
expect(C).toHaveBeenCalledWith({ path: '/c', query: {}, params: {}, rest: '' }, expect.anything());
190190

191191
C.mockClear();
192192
await sleep(10);
193193

194194
expect(scratch).toHaveProperty('innerHTML', '<h1>C</h1>');
195195
expect(B).not.toHaveBeenCalled();
196-
expect(C).toHaveBeenCalledWith({ path: '/c', query: {}, params: {} }, expect.anything());
196+
expect(C).toHaveBeenCalledWith({ path: '/c', query: {}, params: {}, rest: '' }, expect.anything());
197197

198198
// "instant" routing to already-loaded routes
199199

@@ -205,7 +205,7 @@ describe('Router', () => {
205205
expect(scratch).toHaveProperty('innerHTML', '<h1>B</h1><p>hello</p>');
206206
expect(C).not.toHaveBeenCalled();
207207
// expect(B).toHaveBeenCalledTimes(1);
208-
expect(B).toHaveBeenCalledWith({ path: '/b', query: {}, params: {} }, expect.anything());
208+
expect(B).toHaveBeenCalledWith({ path: '/b', query: {}, params: {}, rest: '' }, expect.anything());
209209

210210
B.mockClear();
211211
loc.route('/');
@@ -214,7 +214,7 @@ describe('Router', () => {
214214
expect(scratch).toHaveProperty('innerHTML', '<h1>A</h1><p>hello</p>');
215215
expect(B).not.toHaveBeenCalled();
216216
// expect(A).toHaveBeenCalledTimes(1);
217-
expect(A).toHaveBeenCalledWith({ path: '/', query: {}, params: {} }, expect.anything());
217+
expect(A).toHaveBeenCalledWith({ path: '/', query: {}, params: {}, rest: '' }, expect.anything());
218218
});
219219

220220
describe('intercepted VS external links', () => {
@@ -436,4 +436,66 @@ describe('Router', () => {
436436

437437
pushState.mockRestore();
438438
});
439+
440+
it('should match nested routes', async () => {
441+
let route;
442+
const Inner = () => html`
443+
<${Router}>
444+
<${Route}
445+
path="/bob"
446+
component=${() => {
447+
route = useRoute();
448+
return null;
449+
}}
450+
/>
451+
<//>
452+
`;
453+
454+
render(
455+
html`
456+
<${LocationProvider}>
457+
<${Router}>
458+
<${Route} path="/foo/:id/*" component=${Inner} />
459+
<//>
460+
<a href="/foo/bar/bob"></a>
461+
<//>
462+
`,
463+
scratch
464+
);
465+
466+
scratch.querySelector('a[href="/foo/bar/bob"]').click();
467+
await sleep(20);
468+
expect(route).toMatchObject({ path: '/bob', params: { id: 'bar' } });
469+
});
470+
471+
it('should append params in nested routes', async () => {
472+
let params;
473+
const Inner = () => html`
474+
<${Router}>
475+
<${Route}
476+
path="/bob"
477+
component=${() => {
478+
params = useRoute().params;
479+
return null;
480+
}}
481+
/>
482+
<//>
483+
`;
484+
485+
render(
486+
html`
487+
<${LocationProvider}>
488+
<${Router}>
489+
<${Route} path="/foo/:id/*" component=${Inner} />
490+
<//>
491+
<a href="/foo/bar/bob"></a>
492+
<//>
493+
`,
494+
scratch
495+
);
496+
497+
scratch.querySelector('a[href="/foo/bar/bob"]').click();
498+
await sleep(20);
499+
expect(params).toMatchObject({ id: 'bar' });
500+
});
439501
});

0 commit comments

Comments
 (0)