lit-navigation-router 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -31,8 +31,8 @@ export interface PathRouteConfig extends BaseRouteConfig {
31
31
  *
32
32
  * While `URLPattern` can match against protocols, hostnames, and ports,
33
33
  * routes will only be checked for matches if they're part of the current
34
- * origin. This means that the pattern is limited to checking `pathname` and
35
- * `search`.
34
+ * origin. This means the pattern is limited to checking `pathname`, `search`
35
+ * and `hash`.
36
36
  */
37
37
  export interface URLPatternRouteConfig extends BaseRouteConfig {
38
38
  pattern: URLPatternLike;
@@ -49,12 +49,68 @@ export interface URLPatternRouteConfig extends BaseRouteConfig {
49
49
  * A real `URLPattern` satisfies this, so passing one still type-checks.
50
50
  */
51
51
  export interface URLPatternLike {
52
- test(input: {pathname: string}): boolean;
53
- exec(input: {pathname: string}): {
52
+ /**
53
+ * The pathname pattern string, as `URLPattern.prototype.pathname` returns
54
+ * it. Read to tell a trailing wildcard from any other positional group —
55
+ * the groups object alone cannot (see `tailOf`).
56
+ */
57
+ readonly pathname: string;
58
+ /**
59
+ * The hash pattern string. `'*'` means the route places no constraint on the
60
+ * fragment, which is what `URLPattern` fills in for any component the caller
61
+ * omits. `Router` reads this to decide whether a fragment-only navigation is
62
+ * its business or the browser's.
63
+ */
64
+ readonly hash: string;
65
+ test(input: RouteLocation): boolean;
66
+ exec(input: RouteLocation): {
54
67
  pathname: {groups: {[key: string]: string | undefined}};
68
+ search: {groups: {[key: string]: string | undefined}};
69
+ hash: {groups: {[key: string]: string | undefined}};
55
70
  } | null;
56
71
  }
57
72
 
73
+ /**
74
+ * The parts of a location this router matches against, split out of a path
75
+ * string by `parseLocation`.
76
+ *
77
+ * `search` and `hash` carry no leading `?` or `#`. `URLPattern` canonicalises
78
+ * either form away on an init input, but only for a real `URLPattern` — the
79
+ * structural `URLPatternLike` above admits other implementations, so the
80
+ * delimiters are stripped here rather than left to the pattern.
81
+ */
82
+ export interface RouteLocation {
83
+ pathname: string;
84
+ search: string;
85
+ hash: string;
86
+ }
87
+
88
+ /**
89
+ * Splits `path` into the components a pattern is matched against.
90
+ *
91
+ * Deliberately not `new URL(path, origin)`: a nested controller's path is a
92
+ * *tail* — a bare relative segment like `abc` — which `URL` would resolve
93
+ * against the current directory and mangle.
94
+ */
95
+ const parseLocation = (path: string): RouteLocation => {
96
+ const hashIndex = path.indexOf('#');
97
+ const hash = hashIndex === -1 ? '' : path.slice(hashIndex + 1);
98
+ const beforeHash = hashIndex === -1 ? path : path.slice(0, hashIndex);
99
+ const searchIndex = beforeHash.indexOf('?');
100
+ return {
101
+ pathname: searchIndex === -1 ? beforeHash : beforeHash.slice(0, searchIndex),
102
+ search: searchIndex === -1 ? '' : beforeHash.slice(searchIndex + 1),
103
+ hash,
104
+ };
105
+ };
106
+
107
+ /** Re-attaches `search` and `hash` to a pathname. Inverse of `parseLocation`. */
108
+ const formatLocation = (
109
+ pathname: string,
110
+ {search, hash}: {search: string; hash: string}
111
+ ): string =>
112
+ pathname + (search === '' ? '' : `?${search}`) + (hash === '' ? '' : `#${hash}`);
113
+
58
114
  /**
59
115
  * A description of a route, which path or pattern to match against, and a
60
116
  * render() callback used to render a match to the outlet.
@@ -81,13 +137,46 @@ const getPattern = (route: RouteConfig): URLPatternLike => {
81
137
  return pattern;
82
138
  };
83
139
 
84
- // The implicit "/*" pattern every configured fallback matches against. Built on
85
- // first use rather than at module scope so that importing this module never
86
- // touches `URLPattern` — it may be polyfilled after import, or absent entirely.
87
- let wildcardPattern: URLPatternLike | undefined;
140
+ /**
141
+ * Matches a pathname pattern that ends in a wildcard, in the forms
142
+ * `URLPattern.prototype.pathname` regenerates one: a bare `*` — which a
143
+ * trailing `(.*)` also normalises to — or `{*}`, which the generator emits for
144
+ * a wildcard after a modified group, e.g. `/docs{/}?*`. Either may be
145
+ * optional (`*?`). Not a wildcard: an escaped `\*`, or a `*` that is the
146
+ * modifier on a group (`(\d+)*`, `{/}*`) or on a named param (`:rest*`).
147
+ * Those exclusions matter when an earlier positional group exists —
148
+ * `/x/(\d+)/:rest*` — since that group would otherwise be taken for the tail.
149
+ */
150
+ const TRAILING_WILDCARD = /(?:(?<![\\)}]|:[\w$]+)\*|\{\*\})\??$/;
88
151
 
89
- const getWildcardPattern = (): URLPatternLike =>
90
- (wildcardPattern ??= new URLPattern({pathname: '/*'}));
152
+ /**
153
+ * The tail of a match — what a trailing wildcard (`/foo/*`) captured — or
154
+ * undefined when the pattern has none.
155
+ *
156
+ * Decided from the pattern, not from the groups object: an unnamed regex group
157
+ * (`/post/(\d+)`) and a wildcard that is not last (`/foo/*` followed by
158
+ * `/bar`) are keyed by index exactly as a tail is, and reading either as one
159
+ * truncated `link()` and handed a child the wrong segment. When a trailing
160
+ * wildcard is present it is the last group in the pattern, so its key is the
161
+ * highest positional index.
162
+ */
163
+ const tailOf = (
164
+ route: RouteConfig,
165
+ params: {[key: string]: string | undefined}
166
+ ): string | undefined => {
167
+ if (!TRAILING_WILDCARD.test(getPattern(route).pathname)) {
168
+ return undefined;
169
+ }
170
+ let tailIndex = -1;
171
+ for (const key of Object.keys(params)) {
172
+ // Numeric, not lexicographic: '9' sorts above '10' as a string, so a
173
+ // pattern with eleven or more wildcards picked group 9 as its tail.
174
+ if (/^\d+$/.test(key) && Number(key) > tailIndex) {
175
+ tailIndex = Number(key);
176
+ }
177
+ }
178
+ return tailIndex < 0 ? undefined : params[String(tailIndex)];
179
+ };
91
180
 
92
181
  /**
93
182
  * A reactive controller that performs location-based routing using a
@@ -116,7 +205,10 @@ export class Routes implements ReactiveController {
116
205
 
117
206
  /**
118
207
  * A default fallback route which will always be matched if none of the
119
- * {@link routes} match. Implicitly matches to the path "/*".
208
+ * {@link routes} match. Behaves like a `/*` route: `params[0]` is the whole
209
+ * pathname minus its leading slash, and is handed to child controllers as
210
+ * their tail. A nested controller's own pathname is a tail, with no leading
211
+ * slash; the fallback accepts that too.
120
212
  */
121
213
  fallback?: BaseRouteConfig;
122
214
 
@@ -139,6 +231,16 @@ export class Routes implements ReactiveController {
139
231
  private _gotoSeq = 0;
140
232
 
141
233
  private _currentPathname: string | undefined;
234
+ private _currentTail: string | undefined;
235
+ /**
236
+ * The search and hash of the current location.
237
+ *
238
+ * Ambient rather than tailed: only the pathname nests, so a child controller
239
+ * is handed the parent's tail as its pathname but the *same* search and
240
+ * hash. There is no meaningful way to split a fragment across a route tree.
241
+ */
242
+ private _currentSearch = '';
243
+ private _currentHash = '';
142
244
  private _currentRoute: RouteConfig | undefined;
143
245
  private _currentParams: {
144
246
  [key: string]: string | undefined;
@@ -192,13 +294,12 @@ export class Routes implements ReactiveController {
192
294
  * and the outlet ends up on a route the URL has already left. `Router`
193
295
  * threads `NavigateEvent.signal` through for exactly this reason.
194
296
  */
195
- async goto(pathname: string, options?: {signal?: AbortSignal}) {
297
+ async goto(path: string, options?: {signal?: AbortSignal}) {
196
298
  // TODO (justinfagnani): handle absolute vs relative paths separately.
197
299
 
198
- // TODO (justinfagnani): generalize this to handle query params and
199
- // fragments. It currently only handles path names because it's easier to
200
- // completely disregard the origin for now. The click handler only does
201
- // an in-page navigation if the origin matches anyway.
300
+ const location = parseLocation(path);
301
+ const {pathname} = location;
302
+
202
303
  // Last-goto-wins, per controller. The navigation signal alone is not
203
304
  // enough: a child controller mounts as a *result* of its parent's render,
204
305
  // so its first goto() comes from `_onRoutesConnected` — after the parent's
@@ -207,23 +308,23 @@ export class Routes implements ReactiveController {
207
308
  // newer one. This also keeps `Routes` correct when used on its own, with
208
309
  // no `Router` and no Navigation API in the picture.
209
310
  const seq = ++this._gotoSeq;
210
- let tailGroup: string | undefined;
311
+ let tail: string | undefined;
211
312
 
212
313
  if (this.routes.length === 0 && this.fallback === undefined) {
213
314
  // If a routes controller has none of its own routes it acts like it has
214
315
  // one route of `/*` so that it passes the whole pathname as a tail
215
316
  // match.
216
- tailGroup = pathname;
317
+ tail = pathname;
217
318
  this._currentPathname = '';
218
319
  // Simulate a tail group with the whole pathname
219
- this._currentParams = {0: tailGroup};
320
+ this._currentParams = {0: tail};
220
321
  } else {
221
- const match = this._match(pathname);
322
+ const match = this._match(location);
222
323
  if (match === undefined) {
223
- throw new Error(`No route found for ${pathname}`);
324
+ throw new Error(`No route found for ${path}`);
224
325
  }
225
326
  const {route, params} = match;
226
- tailGroup = getTailGroup(params);
327
+ tail = match.tail;
227
328
  if (typeof route.enter === 'function') {
228
329
  const success = await route.enter(params);
229
330
  // If enter() returns false, cancel this navigation
@@ -240,10 +341,13 @@ export class Routes implements ReactiveController {
240
341
  this._currentRoute = route;
241
342
  this._currentParams = params;
242
343
  this._currentPathname =
243
- tailGroup === undefined
344
+ tail === undefined
244
345
  ? pathname
245
- : pathname.substring(0, pathname.length - tailGroup.length);
346
+ : pathname.substring(0, pathname.length - tail.length);
246
347
  }
348
+ this._currentTail = tail;
349
+ this._currentSearch = location.search;
350
+ this._currentHash = location.hash;
247
351
 
248
352
  // Propagate the tail match to children — deliberately NOT awaited.
249
353
  //
@@ -258,10 +362,13 @@ export class Routes implements ReactiveController {
258
362
  // committed, outlet stranded, i.e. this fork's own thesis bug one level
259
363
  // down. Nested supersession is handled by the goto counter above, not by
260
364
  // awaiting. `_routeChild` covers the per-child filtering and error policy.
261
- if (tailGroup !== undefined) {
262
- for (const childRoutes of this._childRoutes) {
263
- this._routeChild(childRoutes, tailGroup);
264
- }
365
+ //
366
+ // Runs whether or not there is a tail. A route without one has nothing for
367
+ // the children to render, but they must still be superseded — otherwise a
368
+ // child mid-`enter()` for the previous tail stays current and commits over
369
+ // a URL that has moved on.
370
+ for (const childRoutes of this._childRoutes) {
371
+ this._routeChild(childRoutes, tail);
265
372
  }
266
373
  this._host.requestUpdate();
267
374
  }
@@ -291,7 +398,9 @@ export class Routes implements ReactiveController {
291
398
  * a genuine `enter()` rejection still surfaces the way it does upstream.
292
399
  * Skipping must still supersede: `goto()` is where the counter is bumped, so
293
400
  * returning without it would leave an in-flight child navigation current,
294
- * free to commit over a URL that has moved on.
401
+ * free to commit over a URL that has moved on. A parent route with no tail
402
+ * at all is the same case: nothing to route, but still something to stand
403
+ * down.
295
404
  *
296
405
  * No abort signal is threaded through, and the goto is deliberately not
297
406
  * awaited. The parent commits its own state before children run, so a child
@@ -300,12 +409,20 @@ export class Routes implements ReactiveController {
300
409
  * hash-only navigation aborts the outstanding one without producing a
301
410
  * replacement. Supersession is the counter's job.
302
411
  */
303
- private _routeChild(child: Routes, tail: string) {
304
- if (!child.hasRouteFor(tail)) {
412
+ private _routeChild(child: Routes, tail: string | undefined) {
413
+ if (tail === undefined) {
414
+ child._supersede();
415
+ return;
416
+ }
417
+ const childPath = formatLocation(tail, {
418
+ search: this._currentSearch,
419
+ hash: this._currentHash,
420
+ });
421
+ if (!child.hasRouteFor(childPath)) {
305
422
  child._supersede();
306
423
  return;
307
424
  }
308
- void child.goto(tail).catch((err) => {
425
+ void child.goto(childPath).catch((err) => {
309
426
  queueMicrotask(() => {
310
427
  throw err;
311
428
  });
@@ -338,7 +455,32 @@ export class Routes implements ReactiveController {
338
455
  }
339
456
 
340
457
  /**
341
- * True when this controller can render `pathname` — i.e. a route matches, or
458
+ * True when this controller, or any controller below it, has a route that
459
+ * constrains the hash.
460
+ *
461
+ * `Router` gates interception of fragment-only navigation on this. Left
462
+ * ungated, a pathname-only app would have every in-page anchor swallowed and
463
+ * re-rendered instead of scrolled; gated, such an app behaves exactly as it
464
+ * did before hash routes existed.
465
+ *
466
+ * Walks children because a nested controller may route on the hash while the
467
+ * top-level `Router` does not — and only the top-level one sees the
468
+ * navigate event.
469
+ */
470
+ protected _constrainsHash(seen: Set<Routes> = new Set()): boolean {
471
+ // Cycle guard, matching `_supersede`; see the note there.
472
+ if (seen.has(this)) {
473
+ return false;
474
+ }
475
+ seen.add(this);
476
+ return (
477
+ this.routes.some((r) => getPattern(r).hash !== '*') ||
478
+ this._childRoutes.some((c) => c._constrainsHash(seen))
479
+ );
480
+ }
481
+
482
+ /**
483
+ * True when this controller can render `path` — i.e. a route matches, or
342
484
  * a fallback is configured.
343
485
  *
344
486
  * `Router` gates interception on this: intercepting a path we cannot render
@@ -346,7 +488,7 @@ export class Routes implements ReactiveController {
346
488
  * moved and the outlet stale. Letting the browser handle it instead means a
347
489
  * server-rendered page, an export endpoint, or a GET form still works.
348
490
  */
349
- hasRouteFor(pathname: string): boolean {
491
+ hasRouteFor(path: string): boolean {
350
492
  // A fallback matches everything, and a controller with no routes of its own
351
493
  // behaves as if it had a single `/*` route (goto()'s special case). Either
352
494
  // way the answer is yes without running a single pattern — worth
@@ -356,7 +498,8 @@ export class Routes implements ReactiveController {
356
498
  }
357
499
  // `test()`, not `_match()`: this only needs the yes/no, and `exec()` pays
358
500
  // ~8x on a hit to build a groups object the caller would throw away.
359
- return this.routes.some((r) => getPattern(r).test({pathname}));
501
+ const location = parseLocation(path);
502
+ return this.routes.some((r) => getPattern(r).test(location));
360
503
  }
361
504
 
362
505
  /**
@@ -367,28 +510,45 @@ export class Routes implements ReactiveController {
367
510
  * extract: that ran the winning pattern twice, and every caller that wants a
368
511
  * route wants its params too.
369
512
  */
370
- private _match(pathname: string):
371
- | {route: RouteConfig; params: {[key: string]: string | undefined}}
513
+ private _match(location: RouteLocation):
514
+ | {
515
+ route: RouteConfig;
516
+ params: {[key: string]: string | undefined};
517
+ tail: string | undefined;
518
+ }
372
519
  | undefined {
373
520
  for (const route of this.routes) {
374
- const result = getPattern(route).exec({pathname});
521
+ const result = getPattern(route).exec(location);
375
522
  if (result !== null) {
376
- return {route, params: result.pathname.groups};
523
+ const params: {[key: string]: string | undefined} = {
524
+ ...result.pathname.groups,
525
+ };
526
+ // Named groups only. A positional group is keyed by index in every
527
+ // component independently, so `/child/*` with a hash of `*` yields a
528
+ // "0" in both — and `tailOf` picks the tail by highest numeric key.
529
+ // Merging them would let a fragment masquerade as the tail.
530
+ for (const groups of [result.search.groups, result.hash.groups]) {
531
+ for (const [key, value] of Object.entries(groups)) {
532
+ if (!/^\d+$/.test(key)) {
533
+ params[key] = value;
534
+ }
535
+ }
536
+ }
537
+ return {route, params, tail: tailOf(route, result.pathname.groups)};
377
538
  }
378
539
  }
379
540
  if (this.fallback === undefined) {
380
541
  return undefined;
381
542
  }
382
543
  // The fallback route behaves like it has a "/*" path. This is hidden from
383
- // the public API but is added here to return a valid RouteConfig. The
384
- // pattern is the shared one rather than one derived from this object:
385
- // the spread produces a fresh object every call, which `patternCache` —
386
- // keyed by identity — would miss, rebuilding a URLPattern per navigation.
387
- const wildcard = getWildcardPattern();
388
- return {
389
- route: {...this.fallback, path: '/*'},
390
- params: wildcard.exec({pathname})?.pathname.groups ?? {},
391
- };
544
+ // the public API; the `path` is there to return a valid RouteConfig. The
545
+ // match itself is done by hand rather than with a real `/*` pattern: a
546
+ // nested controller is handed its tail *without* a leading slash, which
547
+ // `/*` does not match, so a nested fallback matched nothing — empty
548
+ // params, no tail, and its own children never routed.
549
+ const {pathname} = location;
550
+ const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;
551
+ return {route: {...this.fallback, path: '/*'}, params: {0: tail}, tail};
392
552
  }
393
553
 
394
554
  hostConnected() {
@@ -440,43 +600,12 @@ export class Routes implements ReactiveController {
440
600
 
441
601
  // A child that mounts under an existing tail match has to be caught up to
442
602
  // it — it missed the propagation loop in goto() that ran before it existed.
443
- const tailGroup = getTailGroup(this._currentParams);
444
- if (tailGroup !== undefined) {
445
- this._routeChild(childRoutes, tailGroup);
446
- }
603
+ // With no tail there is nothing to catch up to, and `_routeChild` then only
604
+ // supersedes, a no-op on a freshly mounted child.
605
+ this._routeChild(childRoutes, this._currentTail);
447
606
  };
448
607
  }
449
608
 
450
- /**
451
- * Returns the tail of a pathname groups object. This is the match from a
452
- * wildcard at the end of a pathname pattern, like `/foo/*`
453
- */
454
- const getTailGroup = (groups: {[key: string]: string | undefined}) => {
455
- let tailIndex = -1;
456
- for (const key of Object.keys(groups)) {
457
- // Anchored. `URLPattern` keys a positional group by its index, so a
458
- // non-digit key is never one — an unanchored test also accepts a *named*
459
- // group containing a digit (`:id2`), and since a letter sorts above a
460
- // digit it then won the comparison below and the param value was handed
461
- // to the child instead of the tail.
462
- //
463
- // Necessary, not sufficient: an unnamed *regex* group is positional too
464
- // (`/post/(\d+)` yields key "0"), as is a wildcard that is not last
465
- // (`/foo/*/bar`). Both are mis-read as tails here, and both predate this
466
- // check — selecting the tail properly needs the pattern, not just groups.
467
- if (!/^\d+$/.test(key)) {
468
- continue;
469
- }
470
- // Numeric, not lexicographic: '9' sorts above '10' as a string, so a
471
- // pattern with eleven or more wildcards picked group 9 as its tail.
472
- const index = Number(key);
473
- if (index > tailIndex) {
474
- tailIndex = index;
475
- }
476
- }
477
- return tailIndex < 0 ? undefined : groups[String(tailIndex)];
478
- };
479
-
480
609
  /**
481
610
  * This event is fired from Routes controllers when their host is connected to
482
611
  * announce the child route and potentially connect to a parent routes controller.