@xmachines/play-router 6.0.0 → 7.0.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/README.md +79 -23
- package/dist/base-route-map.js +240 -270
- package/dist/base-route-map.js.map +1 -1
- package/dist/build-tree.js +84 -96
- package/dist/build-tree.js.map +1 -1
- package/dist/create-route-map-from-tree.js +50 -47
- package/dist/create-route-map-from-tree.js.map +1 -1
- package/dist/errors.js +124 -133
- package/dist/errors.js.map +1 -1
- package/dist/find-route.js +88 -105
- package/dist/find-route.js.map +1 -1
- package/dist/index.js +10 -28
- package/dist/provider-lifecycle.js +109 -143
- package/dist/provider-lifecycle.js.map +1 -1
- package/dist/router-bridge-base.d.ts +5 -5
- package/dist/router-bridge-base.d.ts.map +1 -1
- package/dist/router-bridge-base.js +1026 -1601
- package/dist/router-bridge-base.js.map +1 -1
- package/dist/router-sync.js +107 -139
- package/dist/router-sync.js.map +1 -1
- package/dist/types.d.ts +17 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-routes.js +58 -66
- package/dist/validate-routes.js.map +1 -1
- package/dist/xstate/create-route-map.js +36 -35
- package/dist/xstate/create-route-map.js.map +1 -1
- package/dist/xstate/extract-routes.js +66 -83
- package/dist/xstate/extract-routes.js.map +1 -1
- package/dist/xstate/index.js +5 -33
- package/dist/xstate/machine-to-graph.js +98 -109
- package/dist/xstate/machine-to-graph.js.map +1 -1
- package/dist/xstate/query.js +190 -212
- package/dist/xstate/query.js.map +1 -1
- package/package.json +16 -16
- package/dist/index.js.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/xstate/index.js.map +0 -1
- package/dist/xstate/machine-types.js +0 -2
- package/dist/xstate/machine-types.js.map +0 -1
|
@@ -1,1622 +1,1047 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* The class holds the logic that each router bridge shares, and that is 90% of the
|
|
5
|
-
* code of every bridge:
|
|
6
|
-
* - The atom watcher of the direction from the actor to the router
|
|
7
|
-
* - `lastSyncedPath`, for the echo suppression in the direction from the actor to the router
|
|
8
|
-
* - The `isProcessingNavigation` flag, which stops a loop of a guard redirect in `syncActorFromRouter` only
|
|
9
|
-
* - `syncRouterFromActor` and `syncActorFromRouter`, with the read of each URL parameter
|
|
10
|
-
* - The connect and disconnect lifecycle of the RouterBridge protocol
|
|
11
|
-
*
|
|
12
|
-
* A subclass implements the 3 abstract methods of its framework only:
|
|
13
|
-
* - navigateRouter(path): how the bridge tells the framework router to navigate
|
|
14
|
-
* - watchRouterChanges(): how the bridge subscribes to each location change of the router
|
|
15
|
-
* - unwatchRouterChanges(): how the bridge cancels that subscription
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* ```typescript
|
|
19
|
-
* export class MyRouterBridge extends RouterBridgeBase {
|
|
20
|
-
* private unsubscribe: (() => void) | null = null;
|
|
21
|
-
*
|
|
22
|
-
* constructor(private router: MyRouter, actor: RoutableActor, routeMap: { getStateIdByPath(path: string): string | undefined; getPathByStateId(id: string): string | undefined; }) {
|
|
23
|
-
* super(actor, routeMap);
|
|
24
|
-
* }
|
|
25
|
-
*
|
|
26
|
-
* protected navigateRouter(path: string): void {
|
|
27
|
-
* this.router.navigate(path);
|
|
28
|
-
* }
|
|
29
|
-
*
|
|
30
|
-
* protected watchRouterChanges(): void {
|
|
31
|
-
* this.unsubscribe = this.router.subscribe((state) => {
|
|
32
|
-
* this.syncActorFromRouter(state.location.pathname, state.location.search);
|
|
33
|
-
* });
|
|
34
|
-
* }
|
|
35
|
-
*
|
|
36
|
-
* protected unwatchRouterChanges(): void {
|
|
37
|
-
* this.unsubscribe?.();
|
|
38
|
-
* this.unsubscribe = null;
|
|
39
|
-
* }
|
|
40
|
-
* }
|
|
41
|
-
* ```
|
|
42
|
-
*
|
|
43
|
-
* @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
|
|
44
|
-
*/
|
|
45
|
-
import { NO_BASE_PATH, sanitizePathname, splitLocation, holdsUnsubstitutedParam, joinBasePath, parsePattern, resolveBasePath, stripBasePath, } from "@xmachines/play-url";
|
|
1
|
+
import { DuplicateBridgeError, InvalidRoutePatternError, RouterSyncError } from "./errors.js";
|
|
2
|
+
import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap } from "./router-sync.js";
|
|
3
|
+
import { NO_BASE_PATH, holdsUnsubstitutedParam, joinBasePath, parsePattern, resolveBasePath, sanitizePathname, splitLocation, stripBasePath } from "@xmachines/play-url";
|
|
46
4
|
import { watchAtom } from "@xmachines/play-atom";
|
|
47
|
-
|
|
48
|
-
import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap, } from "./router-sync.js";
|
|
5
|
+
//#region packages/play-router/src/router-bridge-base.ts
|
|
49
6
|
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
// Clear the record of the location, or the `false` return reaches the caller
|
|
957
|
-
// and changes nothing. The caller hands the location back for the SAME location
|
|
958
|
-
// that `lastSyncedPath` describes — the suppressed-push branch tests
|
|
959
|
-
// `isEchoOfLastSync` before it calls this method — so the caller read its own
|
|
960
|
-
// record as an echo and returned. The actor then stayed on a route that resolves
|
|
961
|
-
// to no URL, the location it was given reached it never, and no error said so.
|
|
962
|
-
this.lastSyncedPath = null;
|
|
963
|
-
return false;
|
|
964
|
-
}
|
|
965
|
-
this.pushHoldingNavigation(route, resolved);
|
|
966
|
-
return true;
|
|
967
|
-
}
|
|
968
|
-
/**
|
|
969
|
-
* Tests a route of the actor against the last synchronized location.
|
|
970
|
-
*
|
|
971
|
-
* The exact string test covers a stateId, for example `"#home"`, which
|
|
972
|
-
* `pushResolvedRoute` writes to the field when it cannot resolve a path. The query
|
|
973
|
-
* test covers a location that carries a query: the order of the keys of a query is
|
|
974
|
-
* not significant, so `?b=2&a=1` and `?a=1&b=2` are the same location, and the
|
|
975
|
-
* bridge must push neither of them back.
|
|
976
|
-
*
|
|
977
|
-
* The FRAGMENT counts in this direction, and the inbound test ignores it. The two
|
|
978
|
-
* are different questions. `navigateRouter` writes the whole value that the actor
|
|
979
|
-
* gave — `buildRouteUrl` appends `context.hash` — so `"/docs"` and `"/docs#intro"`
|
|
980
|
-
* are two locations to write, and a test that read them as one left the fragment out
|
|
981
|
-
* of the address bar for ever. `isEchoOfLastSync` compares against what the ROUTER
|
|
982
|
-
* reports, and `sanitizePathname` has removed the fragment from that already.
|
|
983
|
-
*/
|
|
984
|
-
testEchoOfLastPush(route) {
|
|
985
|
-
// The FIRST question: did the bridge account for this value of the atom already?
|
|
986
|
-
// The test is an exact string compare, because it asks about the VALUE. A route
|
|
987
|
-
// that resolves to no URL — an unknown stateId, a parameterized pattern — is
|
|
988
|
-
// therefore covered too, and the bridge tries it one time only.
|
|
989
|
-
if (route === this.lastActorRoute)
|
|
990
|
-
return { isEcho: true };
|
|
991
|
-
const location = this.lastSyncedPath;
|
|
992
|
-
// The bridge placed no location yet, so it knows nothing about the address bar.
|
|
993
|
-
// Resolve for the caller, and let it push.
|
|
994
|
-
if (location === null) {
|
|
995
|
-
return { isEcho: false, resolved: this.resolveNavigationPath(route) };
|
|
996
|
-
}
|
|
997
|
-
// The SECOND question: does the router hold the location that this route describes
|
|
998
|
-
// already? A route that IS a path answers it directly.
|
|
999
|
-
if (sameLocation(route, location))
|
|
1000
|
-
return { isEcho: true };
|
|
1001
|
-
// A route that is a stateId answers it only after the resolution. The machine that
|
|
1002
|
-
// mirrors an inbound `play.route` back into its route atom reaches this line:
|
|
1003
|
-
// the bridge wrote "/about" and the actor answers "#about", which is ONE location.
|
|
1004
|
-
// A compare of the raw value alone missed, and the bridge pushed a location that
|
|
1005
|
-
// the router already held — one history entry that BACK cannot escape.
|
|
1006
|
-
//
|
|
1007
|
-
// The resolution travels back to the caller, so `resolveNavigationPath` runs one
|
|
1008
|
-
// time: the method is `protected`, a subclass can override it, and a second call
|
|
1009
|
-
// can answer differently from the first.
|
|
1010
|
-
const resolved = this.resolveNavigationPath(route);
|
|
1011
|
-
if (resolved !== null && sameLocation(resolved, location))
|
|
1012
|
-
return { isEcho: true };
|
|
1013
|
-
return { isEcho: false, resolved };
|
|
1014
|
-
}
|
|
1015
|
-
/**
|
|
1016
|
-
* Resolves an actor route to its concrete URL path, and pushes that path to the
|
|
1017
|
-
* router.
|
|
1018
|
-
*
|
|
1019
|
-
* lastSyncedPath must hold the concrete path, because the callback of the router
|
|
1020
|
-
* fires with the concrete path. The two values therefore match, and
|
|
1021
|
-
* syncActorFromRouter stops. A raw stateId in that field, for example "#home", does
|
|
1022
|
-
* not match the sanitized path ("/") in the watcher.
|
|
1023
|
-
*
|
|
1024
|
-
* When resolveNavigationPath returns null, which happens for a parameterized
|
|
1025
|
-
* pattern, for a wildcard pattern, and for an unknown id, the method skips the push
|
|
1026
|
-
* completely: a push of the raw value writes a stateId, a `:param` pattern, or a
|
|
1027
|
-
* literal `*` into the browser URL. The method still writes the raw route to
|
|
1028
|
-
* lastActorRoute, and it leaves lastSyncedPath as it stands, because that field
|
|
1029
|
-
* records a LOCATION and no location changed. Therefore the dedup guard fires
|
|
1030
|
-
* correctly on the next identical value of the atom.
|
|
1031
|
-
*
|
|
1032
|
-
* @param route - The raw value of the actor route.
|
|
1033
|
-
* @param resolved - The concrete path of that route. The default resolves it here. A
|
|
1034
|
-
* caller that resolved it already passes it, so that `resolveNavigationPath` runs
|
|
1035
|
-
* one time: the method is `protected`, and a subclass can override it.
|
|
1036
|
-
*/
|
|
1037
|
-
pushResolvedRoute(route, resolved = this.resolveNavigationPath(route)) {
|
|
1038
|
-
// The VALUE is accounted for either way, so the dedup stops the next identical fire
|
|
1039
|
-
// of the atom even when the route resolves to no URL.
|
|
1040
|
-
this.lastActorRoute = route;
|
|
1041
|
-
// The LOCATION record takes the concrete path only. A raw stateId here described no
|
|
1042
|
-
// location that a router ever showed, and `isEchoOfLastSync` compares this field
|
|
1043
|
-
// against the location that the router reports: it read a real move as an echo of
|
|
1044
|
-
// this bridge, and the actor heard nothing of it.
|
|
1045
|
-
if (resolved === null)
|
|
1046
|
-
return;
|
|
1047
|
-
this.lastSyncedPath = resolved;
|
|
1048
|
-
// This is the ONE place that adds the base path. `lastSyncedPath` keeps the
|
|
1049
|
-
// machine-side path above, with no prefix, so it still compares directly against
|
|
1050
|
-
// an actor route in `syncRouterFromActor`; `navigateRouter` receives the location
|
|
1051
|
-
// of the host router, because that is the location the framework navigates to.
|
|
1052
|
-
this.navigateRouter(joinBasePath(this.mount.path, resolved));
|
|
1053
|
-
}
|
|
1054
|
-
/**
|
|
1055
|
-
* Writes the actor state when the location of the router changes.
|
|
1056
|
-
*
|
|
1057
|
-
* **A known path:** the method sends a `play.route` event to the actor, with the
|
|
1058
|
-
* stateId of the match, the params, and the query. The `isProcessingNavigation` flag
|
|
1059
|
-
* stops a circular update.
|
|
1060
|
-
*
|
|
1061
|
-
* **An unknown path, or a path with no entry in the map:** the method sends NO
|
|
1062
|
-
* `play.route` event, and the actor state stays as it is. It corrects the browser URL
|
|
1063
|
-
* instead, with a `navigateRouter(actor.currentRoute.get())` call. The URL therefore
|
|
1064
|
-
* follows the actor state, also when the user types an invalid path in the address
|
|
1065
|
-
* bar, or when the code pushes such a path during a session. The method writes the
|
|
1066
|
-
* resolved concrete path to `lastSyncedPath` before the `navigateRouter` call.
|
|
1067
|
-
* Therefore the callback of the router for that navigation stops at the guard of the
|
|
1068
|
-
* echo suppression, and it sends no false event.
|
|
1069
|
-
*/
|
|
1070
|
-
syncActorFromRouter(pathname, search) {
|
|
1071
|
-
if (this.hasConnectedOnce && !this.isConnected)
|
|
1072
|
-
return;
|
|
1073
|
-
if (typeof pathname !== "string")
|
|
1074
|
-
return;
|
|
1075
|
-
const sanitized = sanitizePathname(pathname);
|
|
1076
|
-
if (sanitized === null)
|
|
1077
|
-
return; // The path is too long. Refuse it
|
|
1078
|
-
// Take the machine half of the location. A `null` value means that the location
|
|
1079
|
-
// lies OUTSIDE the mount, so it belongs to the host: return without a send of an
|
|
1080
|
-
// event, and without a correction of the URL. That silence lets the routes of the
|
|
1081
|
-
// host and the routes of the machine share one router.
|
|
1082
|
-
const machinePath = stripBasePath(sanitized, this.mount.path);
|
|
1083
|
-
if (machinePath === null) {
|
|
1084
|
-
this.markOutside();
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
// The test runs BEFORE the echo suppression. The location of a return matches
|
|
1088
|
-
// `lastSyncedPath` in the common case — the host comes back to the mount point it
|
|
1089
|
-
// left — and an early stop there would drop the suppressed push for good.
|
|
1090
|
-
if (this.takeReturnUnderMount(machinePath, search))
|
|
1091
|
-
return;
|
|
1092
|
-
// The location lies under the mount, and this bridge is about to place it, so the
|
|
1093
|
-
// first synchronization of this mount is owed no more. `takeReturnUnderMount`
|
|
1094
|
-
// records it for a RETURN, and it returns at its first line for a bridge that never
|
|
1095
|
-
// left, so the debt survived a first synchronization that reached no decision — an
|
|
1096
|
-
// adapter whose `getInitialRouterPath()` gave `undefined`, or gave `null` to an
|
|
1097
|
-
// actor with no route yet. The first later excursion of the host then came back to
|
|
1098
|
-
// the mount point and read that stale debt as a restore: it pushed the route of the
|
|
1099
|
-
// actor over the location that the user chose, with no `play.route` event for it.
|
|
1100
|
-
this.hasSynchronizedUnderMount = true;
|
|
1101
|
-
if (this.isEchoOfLastSync(machinePath, search))
|
|
1102
|
-
return;
|
|
1103
|
-
if (this.isProcessingNavigation)
|
|
1104
|
-
return;
|
|
1105
|
-
this.isProcessingNavigation = true;
|
|
1106
|
-
try {
|
|
1107
|
-
const nextRoute = buildPlayRouteEvent({
|
|
1108
|
-
pathname: machinePath,
|
|
1109
|
-
search,
|
|
1110
|
-
match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId)),
|
|
1111
|
-
});
|
|
1112
|
-
if (!nextRoute) {
|
|
1113
|
-
// The path is unknown, because the map holds no route of it.
|
|
1114
|
-
// Push the current route of the actor back to the router. The browser URL therefore
|
|
1115
|
-
// follows the actor state, and this corrects an invalid entry in the address bar on a
|
|
1116
|
-
// cold load, and also a navigation to a path that is absent during a session.
|
|
1117
|
-
//
|
|
1118
|
-
// The code calls pushResolvedRoute() directly, and not syncRouterFromActor(). It
|
|
1119
|
-
// therefore passes around the dedup guard, because lastSyncedPath holds the route of
|
|
1120
|
-
// the actor already. pushResolvedRoute sets lastSyncedPath to the resolved concrete
|
|
1121
|
-
// path before the push. Therefore the callback of the router for this navigation, a
|
|
1122
|
-
// hashchange event or a history event, stops at the
|
|
1123
|
-
// sanitized === lastSyncedPath guard, and it sends no false play.route event to the
|
|
1124
|
-
// actor.
|
|
1125
|
-
//
|
|
1126
|
-
// isProcessingNavigation stays true here. The finally block below clears it after
|
|
1127
|
-
// navigateRouter returns. That block therefore guards synchronously against a
|
|
1128
|
-
// re-entrance of each router callback that fires at once.
|
|
1129
|
-
const currentActorRoute = this.actor.currentRoute.get();
|
|
1130
|
-
if (currentActorRoute) {
|
|
1131
|
-
this.pushResolvedRoute(currentActorRoute);
|
|
1132
|
-
}
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
// Record the location BEFORE the send, and record it with its query.
|
|
1136
|
-
//
|
|
1137
|
-
// The route atom of the actor notifies the watcher of the other direction
|
|
1138
|
-
// for this same move, and `syncRouterFromActor` then tests the route of the
|
|
1139
|
-
// actor against this field. Two details decide that test:
|
|
1140
|
-
//
|
|
1141
|
-
// - The write must happen first. A write after the send leaves the previous
|
|
1142
|
-
// path in the field while the watcher runs, so the test misses and the
|
|
1143
|
-
// bridge pushes the location that the router already holds. On a press on
|
|
1144
|
-
// BACK that push is a new history entry, and it replaces the entry that
|
|
1145
|
-
// FORWARD returns to.
|
|
1146
|
-
// - The write must carry the query. A machine that republishes the query
|
|
1147
|
-
// holds `/second?id=1` as its route, and a pathname alone in this field
|
|
1148
|
-
// never matches it.
|
|
1149
|
-
this.lastSyncedPath = withSearch(nextRoute.pathname, search);
|
|
1150
|
-
// The router moved, and the actor has not answered yet, so the value that the
|
|
1151
|
-
// bridge accounted for describes the agreement of the two sides NO MORE. A value
|
|
1152
|
-
// that stayed read the answer of the actor as a repeat of the atom: a guard
|
|
1153
|
-
// redirect BACK to the route the actor came from — the route that the seed of
|
|
1154
|
-
// `connect()` recorded — stopped at the dedup, the bridge pushed nothing, and the
|
|
1155
|
-
// address bar kept the location that the guard refused.
|
|
1156
|
-
this.lastActorRoute = null;
|
|
1157
|
-
this.actor.send(nextRoute.event);
|
|
1158
|
-
}
|
|
1159
|
-
catch (error) {
|
|
1160
|
-
// An InvalidRoutePatternError needs an action of the DEVELOPER: a route of the
|
|
1161
|
-
// machine declares a pattern that URLPattern refuses, and no location can fix it.
|
|
1162
|
-
// It goes to the caller without a change, so that the caller can find it and read
|
|
1163
|
-
// the pattern that it names.
|
|
1164
|
-
if (error instanceof InvalidRoutePatternError) {
|
|
1165
|
-
throw error;
|
|
1166
|
-
}
|
|
1167
|
-
throw new RouterSyncError("Failed to sync actor state from router location.", {
|
|
1168
|
-
cause: error,
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
finally {
|
|
1172
|
-
this.isProcessingNavigation = false;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
// ── The utilities. They are protected, and a subclass overrides one to read the params of its framework ──
|
|
1176
|
-
/**
|
|
1177
|
-
* Reads the path parameters of a URL, with the URLPattern API.
|
|
1178
|
-
*
|
|
1179
|
-
* The API is always present: `@xmachines/play-router` uses the native URLPattern of the
|
|
1180
|
-
* runtime, and it falls back to `urlpattern-polyfill`, which it carries as an ordinary
|
|
1181
|
-
* dependency. A consumer therefore loads nothing, whatever the runtime.
|
|
1182
|
-
*
|
|
1183
|
-
* @param pathname - The real URL path, for example '/profile/john'
|
|
1184
|
-
* @param stateId - The stateId of the match, for the lookup of the route pattern
|
|
1185
|
-
* @returns The path parameters of the read, or an empty object when nothing matches
|
|
1186
|
-
* @throws {InvalidRoutePatternError} When URLPattern cannot compile the route pattern
|
|
1187
|
-
*/
|
|
1188
|
-
extractParams(pathname, stateId) {
|
|
1189
|
-
const pattern = this.routeMap.getPathByStateId(stateId);
|
|
1190
|
-
if (!pattern)
|
|
1191
|
-
return {};
|
|
1192
|
-
return extractRouteParams(pathname, pattern, this.compileOptions);
|
|
1193
|
-
}
|
|
1194
|
-
/**
|
|
1195
|
-
* Resolves a value of an actor route to a concrete URL path for a navigation.
|
|
1196
|
-
*
|
|
1197
|
-
* A bridge that receives a raw `actor.currentRoute` value in `navigateRouter` calls
|
|
1198
|
-
* this method to convert a stateId, with a `#` prefix or without one, into a path.
|
|
1199
|
-
* The method returns `null` when a navigation is not possible:
|
|
1200
|
-
* - an unknown stateId, with no entry in the route map
|
|
1201
|
-
* - a parameterized pattern or a wildcard pattern, for example `/profile/:id` or
|
|
1202
|
-
* `/docs/*`, because no concrete value is available for a real URL
|
|
1203
|
-
* - a string that is no path and no known stateId
|
|
1204
|
-
*
|
|
1205
|
-
* A LITERAL route of the map comes back with each escape resolved: `/tags/c\+\+` gives
|
|
1206
|
-
* the path `/tags/c++`, which is the path that the route matches.
|
|
1207
|
-
*
|
|
1208
|
-
* A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
|
|
1209
|
-
* through `lookupPathByStateId`. Therefore a route map of your own that holds one
|
|
1210
|
-
* form only still resolves the path.
|
|
1211
|
-
*
|
|
1212
|
-
* The result is MACHINE-side: it carries no `basePath`, because it is also the
|
|
1213
|
-
* value that `lastSyncedPath` stores and that the code compares against an actor
|
|
1214
|
-
* route. The code adds the prefix one time, at the `navigateRouter()` call in
|
|
1215
|
-
* `pushResolvedRoute`.
|
|
1216
|
-
*
|
|
1217
|
-
* @param route - The raw value of the actor route: a stateId, a stateId with a `#`, or a concrete path
|
|
1218
|
-
* @returns The concrete machine-side URL path, or `null` when the bridge must skip the navigation
|
|
1219
|
-
*/
|
|
1220
|
-
resolveNavigationPath(route) {
|
|
1221
|
-
const mapped = this.lookupPathByStateId(route);
|
|
1222
|
-
const path = mapped ?? (route.startsWith("/") ? route : null);
|
|
1223
|
-
if (path === null)
|
|
1224
|
-
return null;
|
|
1225
|
-
// A LITERAL route resolves to the one path that it matches, and the source of a
|
|
1226
|
-
// pattern is not always that path: `/tags/c\+\+` matches `/tags/c++`, and
|
|
1227
|
-
// `/i18n/{en}` matches `/i18n/en`. A bridge that pushed the source wrote the
|
|
1228
|
-
// backslashes and the braces into the address bar, where `getStateIdByPath` answered
|
|
1229
|
-
// that location never — so the URL stopped following the actor, and its next move to
|
|
1230
|
-
// the same route was suppressed as an echo of that push.
|
|
1231
|
-
//
|
|
1232
|
-
// The two branches above resolve DIFFERENT amounts, and the difference is what the
|
|
1233
|
-
// string is:
|
|
1234
|
-
//
|
|
1235
|
-
// - A route of the MAP is a pattern source, so every construct in it is grammar and
|
|
1236
|
-
// the parse resolves all of it.
|
|
1237
|
-
// - A route that the ACTOR built is a path already. `buildRouteUrl` of
|
|
1238
|
-
// `@xmachines/play-xstate` reads the whole grammar and resolves every escape, so
|
|
1239
|
-
// a route that IT built carries no backslash. An actor of another kind can still
|
|
1240
|
-
// write one, and a BACKSLASH is the one construct that means grammar in such a
|
|
1241
|
-
// path — and nothing else does. A brace in such a path is a real brace, and
|
|
1242
|
-
// `/i18n/{en}` from an actor is the location `/i18n/{en}`.
|
|
1243
|
-
//
|
|
1244
|
-
// A route of the map is a pattern source WHOLE, and its `?` is a modifier and never
|
|
1245
|
-
// a query. The parse therefore reads all of it, and its answer is complete: a `null`
|
|
1246
|
-
// literal path says that the pattern describes a SET of paths, so the bridge pushes
|
|
1247
|
-
// nothing. A fall to the concrete-path test below answered that question with the
|
|
1248
|
-
// characters of the string instead, and it let every pattern whose grammar carries
|
|
1249
|
-
// no ":" and no "*" through — `/docs/rfc(2119)`, `/books{/v2}?` and `/files/(\d+)`
|
|
1250
|
-
// all reached the address bar as they stand, where `getStateIdByPath` answers them
|
|
1251
|
-
// never.
|
|
1252
|
-
if (mapped != null)
|
|
1253
|
-
return parsePattern(path, this.compileOptions).literalPath;
|
|
1254
|
-
// A route of the ACTOR carries its query, because `context.query` travels in
|
|
1255
|
-
// `currentRoute`, and the PATHNAME half alone reaches the grammar for it. A query
|
|
1256
|
-
// holds a ":" and a "*" legally — a time in "?at=10:30", a URL in "?next=https://x",
|
|
1257
|
-
// a glob in "?q=a*" — and it holds the "?" that the grammar reads as a modifier. A
|
|
1258
|
-
// parse of the whole string called such a route malformed and resolved nothing of
|
|
1259
|
-
// it, so `/tags/c\+\+?ref=nav` went to the address bar with both backslashes; a
|
|
1260
|
-
// PATTERN test of the whole string called it a pattern, so `pushResolvedRoute` wrote
|
|
1261
|
-
// `lastSyncedPath` and navigated nowhere, and the dedup guard then suppressed the
|
|
1262
|
-
// next attempt at the same route as an echo. The suffix carries no grammar, and it
|
|
1263
|
-
// travels to the router unchanged.
|
|
1264
|
-
const { pathname, suffix } = splitLocation(path);
|
|
1265
|
-
// A BACKSLASH is the one construct that reaches an actor route unresolved, and this
|
|
1266
|
-
// is where it resolves.
|
|
1267
|
-
if (pathname.includes("\\")) {
|
|
1268
|
-
const literal = parsePattern(pathname, this.compileOptions).literalPath;
|
|
1269
|
-
// The resolution FAILED, so this route resolves to no concrete path and the
|
|
1270
|
-
// bridge pushes nothing. A fall to the test below refused none of it — a backslash
|
|
1271
|
-
// is neither a ":" nor a "*" — so the SOURCE went to the address bar with its
|
|
1272
|
-
// backslashes, where `getStateIdByPath` answers that location never, and
|
|
1273
|
-
// `pushResolvedRoute` then recorded it and suppressed the next attempt at the same
|
|
1274
|
-
// route as an echo. `encodeURIComponent` leaves "(", ")", "!", "'" and "~" as they
|
|
1275
|
-
// stand, so a param value of `f(x)` under a template that carries an escape —
|
|
1276
|
-
// `/files/c\+\+/:name` — reaches this line and derives no literal path.
|
|
1277
|
-
return literal === null ? null : `${literal}${suffix}`;
|
|
1278
|
-
}
|
|
1279
|
-
// The test is `holdsUnsubstitutedParam` and NOT `isParameterizedPattern`, because
|
|
1280
|
-
// this string is a concrete path and not a pattern. Its own documentation gives the
|
|
1281
|
-
// reason: a real param value carries a character of the grammar often enough, and
|
|
1282
|
-
// the grammar reads a modifier in "/tags/c++" and a group in "/i18n/{en}".
|
|
1283
|
-
if (holdsUnsubstitutedParam(pathname))
|
|
1284
|
-
return null;
|
|
1285
|
-
return path;
|
|
1286
|
-
}
|
|
1287
|
-
/**
|
|
1288
|
-
* Tells you whether the actor already stands on the PATH of a location of the router.
|
|
1289
|
-
*
|
|
1290
|
-
* The `currentRoute` of the actor can be a stateId, for example `"#app.home"`, while a
|
|
1291
|
-
* location of the router is a URL path, for example `"/home"`. Therefore the method
|
|
1292
|
-
* resolves the path to a stateId before it compares. Without this step, a false "the
|
|
1293
|
-
* two are different" starts a synchronization that nothing needs — and, in
|
|
1294
|
-
* `takeReturnUnderMount`, it reads a location that the HOST chose as a restore of the
|
|
1295
|
-
* actor and writes over it.
|
|
1296
|
-
*
|
|
1297
|
-
* The actor is on the path in three cases: the stateId of the match IS the actor
|
|
1298
|
-
* route, the path of the match IS the actor route, or the actor route is a stateId
|
|
1299
|
-
* that resolves to the same registered path as the stateId of the match. Each lookup
|
|
1300
|
-
* goes through {@link lookupPathByStateId}, which tries the form `"#stateId"` and also
|
|
1301
|
-
* the bare form `"stateId"`. Therefore a route map with `"#about"` recognizes an actor
|
|
1302
|
-
* route of `"about"`, and the opposite also works, and this is correct for a
|
|
1303
|
-
* structural map of your own that holds one form.
|
|
1304
|
-
*
|
|
1305
|
-
* An actor route with a concrete path, which starts with `"/"`, goes to a direct
|
|
1306
|
-
* comparison of the pathname against `machinePath`. That comparison runs BEFORE the
|
|
1307
|
-
* map, because a location the map does not hold is still a location the actor can
|
|
1308
|
-
* stand on.
|
|
1309
|
-
*
|
|
1310
|
-
* The QUERY is a question of its own, and {@link sameQuery} answers it. Each caller
|
|
1311
|
-
* asks BOTH, and the two answers do different work. "The two sides are in step" needs
|
|
1312
|
-
* the path AND the query, so a router that carries the only query is a deep link that
|
|
1313
|
-
* the actor has to follow — for a stateId route as much as for a path route. The
|
|
1314
|
-
* RESTORE needs the path, beside `carriesNoOwnSuffix`: an actor that stands here and
|
|
1315
|
-
* carries no query and no fragment of its own has nothing to put in the address bar,
|
|
1316
|
-
* and a push would only rewrite the location the user opened.
|
|
1317
|
-
*
|
|
1318
|
-
* A query that the ACTOR carries is a difference that this branch still reads as a
|
|
1319
|
-
* restore, so an actor at `/?ref=nav` wins against a router at `/?other=1`. `main`
|
|
1320
|
-
* answers the same way, and whether that is a deep link or a restore is a decision
|
|
1321
|
-
* about the rule: issue #18 carries it, and a test pins the behaviour.
|
|
1322
|
-
*
|
|
1323
|
-
* Each lookup runs ONE time, and a caller that needs both answers calls this method
|
|
1324
|
-
* one time too. `getPathByStateId` belongs to the route map: a map of a consumer can
|
|
1325
|
-
* answer differently on a second call, and `RouteMap` pays a cache lookup for each of
|
|
1326
|
-
* them.
|
|
1327
|
-
*
|
|
1328
|
-
* @param machinePath - The machine half of the location of the router, with no query.
|
|
1329
|
-
* @param actorRoute - The raw value of the actor route. It CAN carry a query.
|
|
1330
|
-
*/
|
|
1331
|
-
isActorAtPath(machinePath, actorRoute) {
|
|
1332
|
-
if (actorRoute == null)
|
|
1333
|
-
return false;
|
|
1334
|
-
// The PATHNAME half of the actor route.
|
|
1335
|
-
//
|
|
1336
|
-
// An actor route carries its QUERY, because `context.query` travels in
|
|
1337
|
-
// `currentRoute`, and `machinePath` carries none — `sanitizePathname` cut it off
|
|
1338
|
-
// before the caller stripped the prefix. The two are therefore never the identical
|
|
1339
|
-
// string for a route with a query, and a comparison of the whole strings reported an
|
|
1340
|
-
// actor standing exactly at this location as "somewhere else": `performInitialSync`
|
|
1341
|
-
// read that as a deep link and sent a `play.route` for the state the actor was in
|
|
1342
|
-
// already, so every entry action of that state ran a second time.
|
|
1343
|
-
const { pathname: actorPath } = splitLocation(actorRoute);
|
|
1344
|
-
// An ESCAPED literal route resolves on that same half, and its two forms are never
|
|
1345
|
-
// the identical string: the actor carries `/tags/c\+\+`, which is what the template
|
|
1346
|
-
// writes, and the router stands at `/tags/c++`, which is the path that the route
|
|
1347
|
-
// matches and the path that `resolveNavigationPath` pushes. The resolution reads the
|
|
1348
|
-
// PATHNAME, and not the whole string, for two reasons: a `?` is a modifier of the
|
|
1349
|
-
// grammar that makes `/tags/c\+\+?ref=nav` malformed, and a query would key the
|
|
1350
|
-
// bounded parse cache on a string that no route map holds.
|
|
1351
|
-
const literalPath = actorPath.includes("\\")
|
|
1352
|
-
? parsePattern(actorPath, this.compileOptions).literalPath
|
|
1353
|
-
: null;
|
|
1354
|
-
// The pathname test answers with no map at all, and it has to run FIRST: the lookup
|
|
1355
|
-
// below returns nothing for a location that the map does not hold, and the early
|
|
1356
|
-
// return then reported an actor standing exactly at that location as "somewhere
|
|
1357
|
-
// else". The seed of the constructor hid this for as long as one field carried the
|
|
1358
|
-
// route of the actor AND the location.
|
|
1359
|
-
if (machinePath === (literalPath ?? actorPath))
|
|
1360
|
-
return true;
|
|
1361
|
-
const resolvedStateId = this.routeMap.getStateIdByPath(machinePath);
|
|
1362
|
-
if (resolvedStateId === null || resolvedStateId === undefined)
|
|
1363
|
-
return false;
|
|
1364
|
-
if (resolvedStateId === actorRoute)
|
|
1365
|
-
return true;
|
|
1366
|
-
if (actorRoute.startsWith("/"))
|
|
1367
|
-
return false;
|
|
1368
|
-
const actorStateIdPath = this.lookupPathByStateId(actorRoute);
|
|
1369
|
-
return (actorStateIdPath != null &&
|
|
1370
|
-
actorStateIdPath === this.lookupPathByStateId(resolvedStateId));
|
|
1371
|
-
}
|
|
1372
|
-
/**
|
|
1373
|
-
* Returns the path of the route map for a value of an actor route, and it tries both
|
|
1374
|
-
* forms of a stateId.
|
|
1375
|
-
*
|
|
1376
|
-
* The actor can emit `"#stateId"` while a route map of your own holds the bare form
|
|
1377
|
-
* `"stateId"`, and also the opposite. `RouteMap` makes the canonical form
|
|
1378
|
-
* internally, but the constructor accepts every structural
|
|
1379
|
-
* `{ getStateIdByPath, getPathByStateId }` object: a plain test object and a map of
|
|
1380
|
-
* one adapter are legal, and the documentation says so. Therefore this method tries
|
|
1381
|
-
* both forms: the raw form first, then the form without the `#`, then the form with
|
|
1382
|
-
* the `#`.
|
|
1383
|
-
*
|
|
1384
|
-
* @param route - The raw value of the actor route: a stateId, with a `#` prefix or without one
|
|
1385
|
-
* @returns The path of the map, or `null` or `undefined` when no form is registered
|
|
1386
|
-
*/
|
|
1387
|
-
lookupPathByStateId(route) {
|
|
1388
|
-
const direct = this.routeMap.getPathByStateId(route);
|
|
1389
|
-
if (direct != null)
|
|
1390
|
-
return direct;
|
|
1391
|
-
const alternate = route.startsWith("#") ? route.slice(1) : `#${route}`;
|
|
1392
|
-
return this.routeMap.getPathByStateId(alternate);
|
|
1393
|
-
}
|
|
1394
|
-
/**
|
|
1395
|
-
* Reads the query parameters of a URL search string.
|
|
1396
|
-
*
|
|
1397
|
-
* @param search - The URL search string, for example '?tab=security&page=1'
|
|
1398
|
-
* @returns The query parameters of the read, or an empty object
|
|
1399
|
-
*/
|
|
1400
|
-
extractQuery(search) {
|
|
1401
|
-
return extractQuery(search);
|
|
1402
|
-
}
|
|
1403
|
-
/**
|
|
1404
|
-
* True when a location of the router is the location that this bridge pushed.
|
|
1405
|
-
*
|
|
1406
|
-
* `lastSyncedPath` holds the value of `actor.currentRoute`, and that value carries
|
|
1407
|
-
* the query string of `context.query`, for example `"/dates?trip=one-way"`. A router
|
|
1408
|
-
* callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
|
|
1409
|
-
* removes the query. A comparison of the pathname alone therefore misses for every
|
|
1410
|
-
* route with a query, and the push of this bridge comes back as a `play.route` event
|
|
1411
|
-
* of its own navigation. That echo is not only wasted work: the bridge sends a
|
|
1412
|
-
* `play.route` for a location that the actor stands on already, so every entry action
|
|
1413
|
-
* of that state runs a second time, and a guard that refuses the repeat moves the
|
|
1414
|
-
* actor to a state that no user asked for.
|
|
1415
|
-
*
|
|
1416
|
-
* The query is compared as a MAP, and not as a string: a framework can re-serialize
|
|
1417
|
-
* a search string in another order, and `URLSearchParams` also accepts the form with
|
|
1418
|
-
* a leading `?` and the form without one.
|
|
1419
|
-
*
|
|
1420
|
-
* @param sanitized - The clean pathname of the router callback, with no query.
|
|
1421
|
-
* @param search - The search string of the router callback, when it has one.
|
|
1422
|
-
* @returns True when the location is the echo of the last push of this bridge.
|
|
1423
|
-
*/
|
|
1424
|
-
isEchoOfLastSync(sanitized, search) {
|
|
1425
|
-
const last = this.lastSyncedPath;
|
|
1426
|
-
if (last === null)
|
|
1427
|
-
return false;
|
|
1428
|
-
if (sanitized === last && (search ?? "") === "")
|
|
1429
|
-
return true;
|
|
1430
|
-
// Compare the query of BOTH sides. A pathname test alone reports an echo for a
|
|
1431
|
-
// move that keeps the path and changes the query, which a filter link does, and
|
|
1432
|
-
// which a `validateSearch` of a route does when it injects a default. The actor
|
|
1433
|
-
// then never hears of the move, and the URL and the actor diverge.
|
|
1434
|
-
const { pathname: lastPath, query: lastQuery } = splitLocation(last);
|
|
1435
|
-
if (lastPath !== sanitized)
|
|
1436
|
-
return false;
|
|
1437
|
-
return sortedQuery(lastQuery) === sortedQuery(search ?? "");
|
|
1438
|
-
}
|
|
1439
|
-
/**
|
|
1440
|
-
* Cleans a raw URL pathname of the router, and checks it.
|
|
1441
|
-
*
|
|
1442
|
-
* The method applies the limit of the path length, which is 2048 characters. It
|
|
1443
|
-
* removes a query string and a fragment from the segment of the pathname, and it
|
|
1444
|
-
* normalizes each sequence of slashes.
|
|
1445
|
-
*
|
|
1446
|
-
* An implementation that passes around `syncActorFromRouter()`, for example with a
|
|
1447
|
-
* reactive watcher of its framework that receives a route object of a parse before,
|
|
1448
|
-
* MUST call this method before it gives the path to a lookup in the route map.
|
|
1449
|
-
* `syncActorFromRouter()` calls the method internally. Therefore a bridge that uses
|
|
1450
|
-
* that method calls `sanitizePath` never itself.
|
|
1451
|
-
*
|
|
1452
|
-
* @param pathname - The raw URL pathname of the framework router.
|
|
1453
|
-
* @returns The clean pathname, or `null` when the path is invalid or too long.
|
|
1454
|
-
*/
|
|
1455
|
-
sanitizePath(pathname) {
|
|
1456
|
-
return sanitizePathname(pathname);
|
|
1457
|
-
}
|
|
1458
|
-
/**
|
|
1459
|
-
* Returns the current pathname of the router at the moment of `connect()`.
|
|
1460
|
-
*
|
|
1461
|
-
* `connect()` calls this method one time, for the first synchronization from the URL
|
|
1462
|
-
* to the actor. router.subscribe() fires on a *later* navigation event only. It does
|
|
1463
|
-
* not repeat the location that the router loaded before. A subclass that can read the
|
|
1464
|
-
* current location of its router synchronously, for example with
|
|
1465
|
-
* `router.state.location.pathname`, overrides this method. A deep link and a direct
|
|
1466
|
-
* URL then drive the actor to the correct state, and they leave it not at the default
|
|
1467
|
-
* of its machine.
|
|
1468
|
-
*
|
|
1469
|
-
* The meaning of each return value:
|
|
1470
|
-
* - `string` → the router has a current path, and the base `connect()` writes the actor state from the router
|
|
1471
|
-
* - `null` → the router is active, but it has no current path yet, and the base `connect()` writes the router from the actor
|
|
1472
|
-
* - `undefined` → the adapter does the first synchronization itself, and the base `connect()` does nothing
|
|
1473
|
-
*
|
|
1474
|
-
* The default value is `undefined`. Therefore a bridge without this hook keeps its
|
|
1475
|
-
* earlier behavior.
|
|
1476
|
-
*/
|
|
1477
|
-
getInitialRouterPath() {
|
|
1478
|
-
return undefined;
|
|
1479
|
-
}
|
|
1480
|
-
/**
|
|
1481
|
-
* Returns the current search string of the router at the moment of `connect()`.
|
|
1482
|
-
*
|
|
1483
|
-
* This method is the pair of `getInitialRouterPath()`. `connect()` calls it one time,
|
|
1484
|
-
* to give the query string of the first URL to `syncActorFromRouter()`. It returns
|
|
1485
|
-
* `undefined` when the current URL of the router has no search string, and also when
|
|
1486
|
-
* the subclass overrides the method not. `syncActorFromRouter` then makes an empty
|
|
1487
|
-
* `query: {}` in the event.
|
|
1488
|
-
*
|
|
1489
|
-
* A subclass that overrides `getInitialRouterPath()` and has a query string
|
|
1490
|
-
* overrides this method too.
|
|
1491
|
-
*
|
|
1492
|
-
* @returns The URL search string, for example `"?tab=security"`, or `undefined` when it is not available.
|
|
1493
|
-
*/
|
|
1494
|
-
getInitialRouterSearch() {
|
|
1495
|
-
return undefined;
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
7
|
+
* RouterBridgeBase — the abstract base class of every framework router adapter
|
|
8
|
+
*
|
|
9
|
+
* The class holds the logic that each router bridge shares, and that is 90% of the
|
|
10
|
+
* code of every bridge:
|
|
11
|
+
* - The atom watcher of the direction from the actor to the router
|
|
12
|
+
* - `lastSyncedPath`, for the echo suppression in the direction from the actor to the router
|
|
13
|
+
* - The `isProcessingNavigation` flag, which stops a loop of a guard redirect in `syncActorFromRouter` only
|
|
14
|
+
* - `syncRouterFromActor` and `syncActorFromRouter`, with the read of each URL parameter
|
|
15
|
+
* - The connect and disconnect lifecycle of the RouterBridge protocol
|
|
16
|
+
*
|
|
17
|
+
* A subclass implements the 3 abstract methods of its framework only:
|
|
18
|
+
* - navigateRouter(path): how the bridge tells the framework router to navigate
|
|
19
|
+
* - watchRouterChanges(): how the bridge subscribes to each location change of the router
|
|
20
|
+
* - unwatchRouterChanges(): how the bridge cancels that subscription
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* export class MyRouterBridge extends RouterBridgeBase {
|
|
25
|
+
* private unsubscribe: (() => void) | null = null;
|
|
26
|
+
*
|
|
27
|
+
* constructor(private router: MyRouter, actor: RoutableActor, routeMap: { getStateIdByPath(path: string): string | undefined; getPathByStateId(id: string): string | undefined; }) {
|
|
28
|
+
* super(actor, routeMap);
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* protected navigateRouter(path: string): void {
|
|
32
|
+
* this.router.navigate(path);
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* protected watchRouterChanges(): void {
|
|
36
|
+
* this.unsubscribe = this.router.subscribe((state) => {
|
|
37
|
+
* this.syncActorFromRouter(state.location.pathname, state.location.search);
|
|
38
|
+
* });
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* protected unwatchRouterChanges(): void {
|
|
42
|
+
* this.unsubscribe?.();
|
|
43
|
+
* this.unsubscribe = null;
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The registry of the actors with an active bridge connection, at the module level.
|
|
52
|
+
*
|
|
53
|
+
* The key is the actor instance, and the registry is a WeakMap, which holds no
|
|
54
|
+
* reference. The class uses it to find and refuse a second `connect()` call of the
|
|
55
|
+
* same actor. Such a call makes two `play.route` events for one navigation, and it
|
|
56
|
+
* makes two `lastSyncedPath` values of the echo suppression that contradict each
|
|
57
|
+
* other.
|
|
58
|
+
*
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
var activeBridges = /* @__PURE__ */ new WeakMap();
|
|
62
|
+
var RouterBridgeBase = class {
|
|
63
|
+
actor;
|
|
64
|
+
routeMap;
|
|
65
|
+
isConnected = false;
|
|
66
|
+
hasConnectedOnce = false;
|
|
67
|
+
/**
|
|
68
|
+
* The machine-side location that the router holds, as far as this bridge knows. It
|
|
69
|
+
* carries the query and the fragment, and it carries NO `basePath`.
|
|
70
|
+
*
|
|
71
|
+
* Only code that saw a location, or that wrote one, writes this field:
|
|
72
|
+
* `performInitialSync` when it finds the two sides in step, `takeReturnUnderMount`
|
|
73
|
+
* for the same test under a mount, `pushResolvedRoute` after it resolves a route to
|
|
74
|
+
* a concrete path, and `syncActorFromRouter` for the location that the router
|
|
75
|
+
* reports. `null` means that this bridge placed no location yet, so it knows nothing
|
|
76
|
+
* about the address bar, and every test against this field must then answer "no".
|
|
77
|
+
*
|
|
78
|
+
* The field held the route of the actor as well, from the seed of the constructor
|
|
79
|
+
* and from a route that resolves to no URL. Those two values describe no location
|
|
80
|
+
* that a router ever showed, and each reader of this field asks about a location, so
|
|
81
|
+
* each of them read them wrongly. {@link lastActorRoute} holds them now.
|
|
82
|
+
*/
|
|
83
|
+
lastSyncedPath = null;
|
|
84
|
+
/**
|
|
85
|
+
* The raw value of `actor.currentRoute` that this bridge accounted for already.
|
|
86
|
+
*
|
|
87
|
+
* The field answers ONE question: "did the bridge see this value of the atom
|
|
88
|
+
* before?". It is therefore a string, and not a location — a route that resolves to
|
|
89
|
+
* no URL, such as an unknown stateId or a parameterized pattern, still stops a
|
|
90
|
+
* second attempt at the same value.
|
|
91
|
+
*
|
|
92
|
+
* The constructor and `connect()` seed it, so that the first fire of the watcher of
|
|
93
|
+
* the atom pushes nothing for a route that moved nowhere.
|
|
94
|
+
*/
|
|
95
|
+
lastActorRoute = null;
|
|
96
|
+
/**
|
|
97
|
+
* The flag guards `syncActorFromRouter` against a re-entrant call from a guard
|
|
98
|
+
* redirect of the actor itself. Such a call has this sequence: the bridge sends to
|
|
99
|
+
* the actor, the atom fires, the bridge pushes to the router, and a second
|
|
100
|
+
* `syncActorFromRouter` call starts before the first one returns.
|
|
101
|
+
*
|
|
102
|
+
* The flag is NOT the echo suppression of the direction from the actor to the
|
|
103
|
+
* router. `lastSyncedPath` does that work alone: the bridge writes it before the
|
|
104
|
+
* `navigateRouter()` call. Therefore each router callback of the same location stops
|
|
105
|
+
* at the {@link isEchoOfLastSync} test in `syncActorFromRouter`.
|
|
106
|
+
*/
|
|
107
|
+
isProcessingNavigation = false;
|
|
108
|
+
/**
|
|
109
|
+
* Who owns the address bar, and what this bridge owes it.
|
|
110
|
+
*
|
|
111
|
+
* - `"under-mount"` — the location lies under the mount, so this bridge owns the
|
|
112
|
+
* address bar and it writes the location for every route of its actor.
|
|
113
|
+
* - `"outside"` — the location lies outside the mount, so the HOST owns it. The
|
|
114
|
+
* bridge sends no event and it corrects no URL, which is what lets the routes of
|
|
115
|
+
* the host and the routes of the machine share one router.
|
|
116
|
+
* - `"outside-push-owed"` — outside, AND the route of the actor changed while the
|
|
117
|
+
* host owned the address bar. The push waits here instead of going to the router,
|
|
118
|
+
* and {@link RouterBridgeBase.takeReturnUnderMount} spends it when the location
|
|
119
|
+
* comes back. The machine therefore keeps its place while the user reads a page of
|
|
120
|
+
* the host.
|
|
121
|
+
*
|
|
122
|
+
* ONE value, and not a latch plus a memory. The memory only ever means something
|
|
123
|
+
* while the latch is up, so two fields could hold a fourth combination that says
|
|
124
|
+
* "the bridge owns the address bar AND a push waits for a return" — a state with no
|
|
125
|
+
* meaning, which a return would spend at a location the router already holds. Two
|
|
126
|
+
* writes also cannot happen together: every site that lowered the latch wrote the
|
|
127
|
+
* memory on the NEXT line, and the value between the two lines was that fourth
|
|
128
|
+
* combination. `takeReturnUnderMount` reads the value it replaces instead.
|
|
129
|
+
*
|
|
130
|
+
* `syncActorFromRouter` is the one place that sees a location, so it is the one
|
|
131
|
+
* place that decides between `"under-mount"` and `"outside"`.
|
|
132
|
+
* `performInitialSync` decides it for the location that the page loaded with.
|
|
133
|
+
*
|
|
134
|
+
* A bridge with no mount never leaves its mount: `stripBasePath` returns the path
|
|
135
|
+
* unchanged for an empty prefix, and never `null`. The value therefore stays
|
|
136
|
+
* `"under-mount"` for every bridge that takes no `basePath`.
|
|
137
|
+
*/
|
|
138
|
+
mountState = "under-mount";
|
|
139
|
+
/**
|
|
140
|
+
* True once the first synchronization ran against a location INSIDE the mount.
|
|
141
|
+
*
|
|
142
|
+
* `performInitialSync` separates a deep link from a restore, and it can make that
|
|
143
|
+
* decision only for a location that lies under the mount. A bridge that connects
|
|
144
|
+
* while the host owns the address bar therefore reaches no decision at all: it
|
|
145
|
+
* raises the latch and returns. The flag says that the decision is still owed, and
|
|
146
|
+
* `syncActorFromRouter` pays it at the first location that IS under the mount —
|
|
147
|
+
* through {@link RouterBridgeBase.takeReturnUnderMount} for a RETURN, which needs the
|
|
148
|
+
* decision itself, and directly for every other location under the mount.
|
|
149
|
+
*
|
|
150
|
+
* A bridge with no mount never leaves its mount, so `performInitialSync` writes this
|
|
151
|
+
* flag on the first `connect()` and nothing reads it again.
|
|
152
|
+
*/
|
|
153
|
+
hasSynchronizedUnderMount = false;
|
|
154
|
+
routeWatcher = null;
|
|
155
|
+
/**
|
|
156
|
+
* The mount point of the machine inside the host router: the resolved prefix, and
|
|
157
|
+
* the values of each `:param` of that prefix.
|
|
158
|
+
*
|
|
159
|
+
* The prefix lives here, and NOT in the route map, for two reasons. A route map is
|
|
160
|
+
* static, it is shared, and it holds an LRU cache inside, while a mount point is
|
|
161
|
+
* dynamic. One route map therefore serves every `machineId` without a rebuild. And
|
|
162
|
+
* the bridge is the boundary between a location of the host router and a path of
|
|
163
|
+
* the machine already, which is exactly what the prefix separates.
|
|
164
|
+
*
|
|
165
|
+
* `lastSyncedPath` stays MACHINE-side, and it carries no prefix. The code adds the
|
|
166
|
+
* prefix at the one `navigateRouter()` call in `pushResolvedRoute`, and it removes
|
|
167
|
+
* the prefix at the one inbound entry point `syncActorFromRouter`. Every test of
|
|
168
|
+
* the echo suppression therefore keeps comparing like with like.
|
|
169
|
+
*/
|
|
170
|
+
mount = NO_BASE_PATH;
|
|
171
|
+
/**
|
|
172
|
+
* The caches that every parse and every compilation of this bridge reads.
|
|
173
|
+
*
|
|
174
|
+
* `resolveNavigationPath` and `isActorAtPath` parse a CONCRETE location, and an
|
|
175
|
+
* application writes one for each value that it puts in a param.
|
|
176
|
+
*/
|
|
177
|
+
compileOptions;
|
|
178
|
+
/**
|
|
179
|
+
* @param actor - A `RoutableActor`, with `currentRoute`, `initialRoute`, and `send`.
|
|
180
|
+
* @param routeMap - The route map of both directions, for the resolution between a
|
|
181
|
+
* `stateId` and a `path`. Give `getStateIdByPath` and `getPathByStateId`. A
|
|
182
|
+
* framework adapter usually wraps the result of `createRouteMap(machine)`, or an
|
|
183
|
+
* equivalent value. The key of `getPathByStateId` is the form `"#stateId"` or the
|
|
184
|
+
* bare form `"stateId"`. The bridge tries both forms. Therefore an implementation
|
|
185
|
+
* of your own, for example a plain test object, handles one form only.
|
|
186
|
+
*/
|
|
187
|
+
constructor(actor, routeMap, options) {
|
|
188
|
+
this.actor = actor;
|
|
189
|
+
this.routeMap = routeMap;
|
|
190
|
+
this.compileOptions = {
|
|
191
|
+
patternCache: options?.patternCache,
|
|
192
|
+
compiledCache: options?.compiledCache
|
|
193
|
+
};
|
|
194
|
+
this.mount = resolveBasePath(options?.basePath, options?.basePathParams);
|
|
195
|
+
this.lastActorRoute = this.actor.currentRoute.get() ?? null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The resolved URL prefix that the machine of this bridge is mounted under, or `""`
|
|
199
|
+
* when the machine owns the complete router.
|
|
200
|
+
*/
|
|
201
|
+
get basePath() {
|
|
202
|
+
return this.mount.path;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The resolved values of the `:param` segments of the mount, or `{}` for a prefix
|
|
206
|
+
* without a param.
|
|
207
|
+
*
|
|
208
|
+
* These params belong to the HOST: the host wrote the prefix and resolved the
|
|
209
|
+
* values, and they describe the route of the host and not the route of the machine.
|
|
210
|
+
* They therefore travel in NO `play.route` event. The machine is authoritative over
|
|
211
|
+
* its own params, and `event.params` holds what the pattern of the machine
|
|
212
|
+
* declares, and nothing else.
|
|
213
|
+
*
|
|
214
|
+
* They live here instead, because a value that reached the actor on a navigation
|
|
215
|
+
* ALONE would go stale: `setBasePath()` can move the mount while the machine stays
|
|
216
|
+
* on the same route, no event goes out, and `event.params` would then contradict
|
|
217
|
+
* `basePath`. A read of this accessor cannot go stale.
|
|
218
|
+
*
|
|
219
|
+
* A machine that needs the identity of its host — a `machineId`, a tenant — takes
|
|
220
|
+
* it through the `input` of the actor, where it belongs: that identity decides
|
|
221
|
+
* WHICH machine runs, and it is not a param of a route inside the machine.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```typescript
|
|
225
|
+
* bridge.basePath; // "/abc123/play"
|
|
226
|
+
* bridge.basePathParams; // { machineId: "abc123" }
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
get basePathParams() {
|
|
230
|
+
return this.mount.params;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Moves the machine to a different mount point, while it stays connected.
|
|
234
|
+
*
|
|
235
|
+
* This is the "load and unload" half of a shared router. It moves WHERE an actor is
|
|
236
|
+
* mounted, and never WHICH actor is mounted: the actor, the route map, and its LRU
|
|
237
|
+
* cache all stay, and nothing goes away.
|
|
238
|
+
*
|
|
239
|
+
* An actor never changes identity. A prefix that IDENTIFIES the actor — a
|
|
240
|
+
* `machineId` that names the document it runs — therefore never moves through this
|
|
241
|
+
* method: a new identity is a new actor, and a new actor takes a new bridge, because
|
|
242
|
+
* `connect()` permits one bridge for each actor. The segments that move here are the
|
|
243
|
+
* ones that LOCATE: a region, a locale, a tenant, a workspace slug.
|
|
244
|
+
*
|
|
245
|
+
* The call then brings the location in step with the NEW prefix. A location inside
|
|
246
|
+
* the new mount runs the same first-synchronization decision as `connect()`: it
|
|
247
|
+
* drives the actor, and a restore keeps the route of the actor. A location OUTSIDE
|
|
248
|
+
* the new mount is the old mount in practice, so the bridge writes the new one
|
|
249
|
+
* itself, and it keeps the route of the actor while it does so — nothing else moves
|
|
250
|
+
* the address bar. A call that resolves to the current prefix changes no location,
|
|
251
|
+
* and it therefore reconciles nothing.
|
|
252
|
+
*
|
|
253
|
+
* @param basePath - The new prefix, as a pattern or as a concrete path. An absent
|
|
254
|
+
* value, `""`, and `"/"` all remove the prefix, and they give the machine the
|
|
255
|
+
* complete router again.
|
|
256
|
+
* @param basePathParams - The values of the `:param` segments of `basePath`.
|
|
257
|
+
* @throws {InvalidBasePathError} For a prefix that resolves to one concrete path never.
|
|
258
|
+
* @throws {MissingBasePathParamError} When a `:param` of the prefix has no value.
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* ```typescript
|
|
262
|
+
* // The host moved this actor to another place in its URL space. `machineId` names
|
|
263
|
+
* // WHICH document the actor runs, and it does not change: a new identity is a new
|
|
264
|
+
* // actor, and therefore a new bridge, because one actor takes one bridge.
|
|
265
|
+
* bridge.setBasePath("/:region/:machineId/play", { region: "us", machineId });
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
setBasePath(basePath, basePathParams) {
|
|
269
|
+
const next = resolveBasePath(basePath, basePathParams);
|
|
270
|
+
const samePath = next.path === this.mount.path;
|
|
271
|
+
this.mount = next;
|
|
272
|
+
if (samePath) return;
|
|
273
|
+
if (this.isConnected) {
|
|
274
|
+
this.lastSyncedPath = null;
|
|
275
|
+
this.mountState = "under-mount";
|
|
276
|
+
this.hasSynchronizedUnderMount = false;
|
|
277
|
+
this.reconcileLocationWithMount();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Brings the location of the router in step with a mount that just moved.
|
|
282
|
+
*
|
|
283
|
+
* **The location lies under the new mount:** run the same first-synchronization
|
|
284
|
+
* decision as `connect()`, so a location inside the mount drives the actor and a
|
|
285
|
+
* restore keeps the route of the actor.
|
|
286
|
+
*
|
|
287
|
+
* **The location lies outside it:** the bridge writes the new mount ITSELF, and it
|
|
288
|
+
* keeps the route of the actor while it does so. A host that re-points a mount
|
|
289
|
+
* changes no location by itself, and nothing else moves the address bar. Without
|
|
290
|
+
* this push the machine sits at a mount that no location is under: it hears no
|
|
291
|
+
* `play.route`, `lastSyncedPath` is clear already, and the stale prefix stays in the
|
|
292
|
+
* URL until the route of the actor happens to change — which then jumps the URL with
|
|
293
|
+
* no reason that the history of the user explains.
|
|
294
|
+
*
|
|
295
|
+
* `pushResolvedRoute` writes `lastSyncedPath` before it navigates, so the callback
|
|
296
|
+
* of the router for this push stops at the echo suppression, and it sends no event.
|
|
297
|
+
*/
|
|
298
|
+
reconcileLocationWithMount() {
|
|
299
|
+
const routerPath = this.getInitialRouterPath();
|
|
300
|
+
const sanitized = typeof routerPath === "string" ? sanitizePathname(routerPath) : null;
|
|
301
|
+
if (typeof routerPath !== "string" || sanitized === null) {
|
|
302
|
+
this.performInitialSync(routerPath);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (stripBasePath(sanitized, this.mount.path) !== null) {
|
|
306
|
+
this.performInitialSync(routerPath);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const currentActorRoute = this.actor.currentRoute.get();
|
|
310
|
+
if (!currentActorRoute) {
|
|
311
|
+
this.markOutside();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const resolved = this.resolveNavigationPath(currentActorRoute);
|
|
315
|
+
if (resolved === null) {
|
|
316
|
+
this.mountState = "outside-push-owed";
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this.mountState = "under-mount";
|
|
320
|
+
this.hasSynchronizedUnderMount = true;
|
|
321
|
+
this.pushHoldingNavigation(currentActorRoute, resolved);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Pushes a route to the router, and it holds {@link isProcessingNavigation} across
|
|
325
|
+
* the write.
|
|
326
|
+
*
|
|
327
|
+
* Every corrective push of the bridge needs that flag: `navigateRouter` makes the
|
|
328
|
+
* router call `syncActorFromRouter` again inside it, and a router that NORMALIZES the
|
|
329
|
+
* location it receives — a `validateSearch` that injects a default, a trailing slash
|
|
330
|
+
* that a history adds — reports a value that the echo test misses. The bridge would
|
|
331
|
+
* then answer its own write with a `play.route` event, and on a press of BACK that
|
|
332
|
+
* answer is a history entry that FORWARD cannot return to.
|
|
333
|
+
*
|
|
334
|
+
* The one push that does NOT come through here is `syncRouterFromActor`: that push
|
|
335
|
+
* follows the actor, and the echo suppression of `lastSyncedPath` is what stops it.
|
|
336
|
+
*
|
|
337
|
+
* @param route - The raw value of the actor route.
|
|
338
|
+
* @param resolved - The concrete path of that route, when the caller resolved it
|
|
339
|
+
* already. `undefined` resolves it in `pushResolvedRoute`.
|
|
340
|
+
*/
|
|
341
|
+
pushHoldingNavigation(route, resolved) {
|
|
342
|
+
this.isProcessingNavigation = true;
|
|
343
|
+
try {
|
|
344
|
+
this.pushResolvedRoute(route, resolved);
|
|
345
|
+
} finally {
|
|
346
|
+
this.isProcessingNavigation = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Connects the router bridge to the Actor.
|
|
351
|
+
*
|
|
352
|
+
* The method installs the atom watcher of the direction from the actor to the
|
|
353
|
+
* router. It then starts the watch of the router changes, which each framework does
|
|
354
|
+
* in its own way.
|
|
355
|
+
*
|
|
356
|
+
* The order of these steps is part of the contract of the bridge:
|
|
357
|
+
* - The constructor seeds `lastActorRoute` from `actor.currentRoute`, and it records no location
|
|
358
|
+
* - The method installs the actor watcher before the router subscriptions of the adapter
|
|
359
|
+
* - The first synchronization then separates a deep link from a restore, with `actor.initialRoute`
|
|
360
|
+
*
|
|
361
|
+
* An adapter that needs a different behavior of the first synchronization overrides
|
|
362
|
+
* `getInitialRouterPath()`. It does not change the order of the steps of
|
|
363
|
+
* `connect()`.
|
|
364
|
+
*/
|
|
365
|
+
connect() {
|
|
366
|
+
if (this.isConnected) return;
|
|
367
|
+
const existingBridge = activeBridges.get(this.actor);
|
|
368
|
+
if (existingBridge !== void 0 && existingBridge !== this) throw new DuplicateBridgeError();
|
|
369
|
+
activeBridges.set(this.actor, this);
|
|
370
|
+
this.lastActorRoute = this.actor.currentRoute.get() ?? null;
|
|
371
|
+
this.lastSyncedPath = null;
|
|
372
|
+
this.isConnected = true;
|
|
373
|
+
this.hasConnectedOnce = true;
|
|
374
|
+
try {
|
|
375
|
+
this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
|
|
376
|
+
this.syncRouterFromActor(route);
|
|
377
|
+
});
|
|
378
|
+
this.watchRouterChanges();
|
|
379
|
+
this.performInitialSync(this.getInitialRouterPath());
|
|
380
|
+
} catch (error) {
|
|
381
|
+
try {
|
|
382
|
+
this.disconnect();
|
|
383
|
+
} catch {}
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Brings the actor and the router in step one time, and decides which side wins.
|
|
389
|
+
*
|
|
390
|
+
* The method is separate from `connect()`, so that `connect()` can roll the whole
|
|
391
|
+
* first synchronization back when it throws. See the `try` block there.
|
|
392
|
+
*
|
|
393
|
+
* The first synchronization: the direction from the router to the actor has the
|
|
394
|
+
* priority over the other direction.
|
|
395
|
+
*
|
|
396
|
+
* The page can load on a URL that is not the initial state of the actor. For
|
|
397
|
+
* example, the user types "/about", or the user follows a deep link. The router
|
|
398
|
+
* holds the correct path then, and the actor is still at its initial state ("/").
|
|
399
|
+
* The code must therefore drive the actor to the URL, and it must not write the
|
|
400
|
+
* default of the actor to the URL.
|
|
401
|
+
*
|
|
402
|
+
* router.subscribe() fires on a *later* navigation event only. It does NOT fire for
|
|
403
|
+
* the location that the page loaded. A subclass that can read the current location
|
|
404
|
+
* of its router synchronously overrides getInitialRouterPath().
|
|
405
|
+
*
|
|
406
|
+
* @param initialRouterPath - The location of the router. Every caller reads it and
|
|
407
|
+
* passes it, and this parameter carries NO default: a default fires for an
|
|
408
|
+
* explicit `undefined` too, so `reconcileLocationWithMount` asked the adapter a
|
|
409
|
+
* SECOND time for exactly the value that says "the adapter does the first
|
|
410
|
+
* synchronization itself" — and a read belongs to the adapter, which can walk the
|
|
411
|
+
* state of a router and answer differently on the second call.
|
|
412
|
+
*/
|
|
413
|
+
performInitialSync(initialRouterPath) {
|
|
414
|
+
const initialActorRoute = this.actor.currentRoute.get();
|
|
415
|
+
let search;
|
|
416
|
+
let searchRead = false;
|
|
417
|
+
const routerSearch = () => {
|
|
418
|
+
if (!searchRead) {
|
|
419
|
+
search = this.getInitialRouterSearch();
|
|
420
|
+
searchRead = true;
|
|
421
|
+
}
|
|
422
|
+
return search;
|
|
423
|
+
};
|
|
424
|
+
if (typeof initialRouterPath === "string") {
|
|
425
|
+
const sanitized = sanitizePathname(initialRouterPath);
|
|
426
|
+
if (sanitized === null) return;
|
|
427
|
+
const machinePath = stripBasePath(sanitized, this.mount.path);
|
|
428
|
+
if (machinePath === null) {
|
|
429
|
+
this.markOutside();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
this.mountState = "under-mount";
|
|
433
|
+
this.hasSynchronizedUnderMount = true;
|
|
434
|
+
const actorAtRouterPath = this.isActorAtPath(machinePath, initialActorRoute);
|
|
435
|
+
if (actorAtRouterPath && initialActorRoute != null && sameQuery(initialActorRoute, routerSearch())) this.lastSyncedPath = withSearch(machinePath, routerSearch());
|
|
436
|
+
else if (initialActorRoute && isAtInitialLocation(this.actor.initialRoute, machinePath, routerSearch()) && initialActorRoute !== this.actor.initialRoute && !(actorAtRouterPath && carriesNoOwnSuffix(initialActorRoute))) this.pushHoldingNavigation(initialActorRoute);
|
|
437
|
+
else this.syncActorFromRouter(initialRouterPath, routerSearch());
|
|
438
|
+
} else if (initialActorRoute && initialRouterPath === null) {
|
|
439
|
+
this.pushHoldingNavigation(initialActorRoute);
|
|
440
|
+
this.hasSynchronizedUnderMount = true;
|
|
441
|
+
} else if (initialActorRoute && initialActorRoute !== this.lastActorRoute) {
|
|
442
|
+
this.syncRouterFromActor(initialActorRoute);
|
|
443
|
+
this.hasSynchronizedUnderMount = true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Disconnects the router bridge from the Actor.
|
|
448
|
+
*
|
|
449
|
+
* The method stops the watch of the atom, and it removes the router listener of the
|
|
450
|
+
* framework.
|
|
451
|
+
*/
|
|
452
|
+
disconnect() {
|
|
453
|
+
const hadRouteWatcher = this.routeWatcher !== null;
|
|
454
|
+
if (this.routeWatcher) try {
|
|
455
|
+
this.routeWatcher.unwatch();
|
|
456
|
+
} catch {}
|
|
457
|
+
this.routeWatcher = null;
|
|
458
|
+
try {
|
|
459
|
+
if (this.isConnected || hadRouteWatcher) this.unwatchRouterChanges();
|
|
460
|
+
} catch {} finally {
|
|
461
|
+
this.isProcessingNavigation = false;
|
|
462
|
+
this.isConnected = false;
|
|
463
|
+
this.mountState = "under-mount";
|
|
464
|
+
this.hasSynchronizedUnderMount = false;
|
|
465
|
+
if (activeBridges.get(this.actor) === this) activeBridges.delete(this.actor);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Writes the location of the router when the route atom of the actor changes.
|
|
470
|
+
*
|
|
471
|
+
* The method resolves the actor route to a concrete URL path, then it calls
|
|
472
|
+
* navigateRouter() for the navigation of the framework. When it cannot resolve the
|
|
473
|
+
* route, which happens for an unknown stateId and for a parameterized pattern
|
|
474
|
+
* without concrete values, it skips the push completely: `navigateRouter` receives a
|
|
475
|
+
* raw stateId or a pattern never.
|
|
476
|
+
*
|
|
477
|
+
* `lastSyncedPath` does the complete echo suppression, which stops the callback of
|
|
478
|
+
* the router from a send to the actor: the method writes the resolved path to
|
|
479
|
+
* `lastSyncedPath` before the `navigateRouter()` call. Therefore each
|
|
480
|
+
* `syncActorFromRouter` call of the same path stops at the
|
|
481
|
+
* `sanitized === lastSyncedPath` test, and it sends no event. This is correct for a
|
|
482
|
+
* synchronous callback and for an asynchronous callback.
|
|
483
|
+
*
|
|
484
|
+
* The method does NOT set `isProcessingNavigation`. That flag lives inside
|
|
485
|
+
* `syncActorFromRouter`, and it guards against a re-entrant loop of a guard
|
|
486
|
+
* redirect.
|
|
487
|
+
*/
|
|
488
|
+
syncRouterFromActor(route) {
|
|
489
|
+
if (this.hasConnectedOnce && !this.isConnected) return;
|
|
490
|
+
if (!route || typeof route !== "string") return;
|
|
491
|
+
const echo = this.testEchoOfLastPush(route);
|
|
492
|
+
if (this.mountState !== "under-mount") {
|
|
493
|
+
this.mountState = echo.isEcho ? "outside" : "outside-push-owed";
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (echo.isEcho) return;
|
|
497
|
+
this.pushResolvedRoute(route, echo.resolved);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Records that the location lies outside the mount, so the HOST owns the address bar.
|
|
501
|
+
*
|
|
502
|
+
* A bridge that is outside ALREADY keeps the value it holds: `"outside-push-owed"`
|
|
503
|
+
* says that a push waits for the address bar to come back, and a second location of
|
|
504
|
+
* the host is no reason to forget it.
|
|
505
|
+
*/
|
|
506
|
+
markOutside() {
|
|
507
|
+
if (this.mountState === "under-mount") this.mountState = "outside";
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Takes the return of the address bar under the mount.
|
|
511
|
+
*
|
|
512
|
+
* The actor wins that return when it moved while the host owned the location AND the
|
|
513
|
+
* address bar came back to the location it still held: the method pushes the route of
|
|
514
|
+
* the actor, and the caller sends no `play.route` event. A return to any other route
|
|
515
|
+
* of the mount belongs to the host, so the memory goes and the caller drives the
|
|
516
|
+
* actor from the location instead.
|
|
517
|
+
*
|
|
518
|
+
* The method also pays the first synchronization that the mount still owes. A bridge
|
|
519
|
+
* that connected while the host owned the address bar reached no decision between a
|
|
520
|
+
* deep link and a restore: `performInitialSync` can decide only for a location under
|
|
521
|
+
* the mount, and it found none. This is the first such location, so the decision runs
|
|
522
|
+
* here, with the same rule.
|
|
523
|
+
*
|
|
524
|
+
* The method holds `isProcessingNavigation` across each push, because
|
|
525
|
+
* `navigateRouter` makes the router call `syncActorFromRouter` again.
|
|
526
|
+
*
|
|
527
|
+
* @returns `true` when the method handled the location, so the caller returns.
|
|
528
|
+
*/
|
|
529
|
+
takeReturnUnderMount(machinePath, search) {
|
|
530
|
+
const previous = this.mountState;
|
|
531
|
+
if (previous === "under-mount") return false;
|
|
532
|
+
this.mountState = "under-mount";
|
|
533
|
+
const owesFirstSync = !this.hasSynchronizedUnderMount;
|
|
534
|
+
this.hasSynchronizedUnderMount = true;
|
|
535
|
+
const restoredRoute = this.actor.currentRoute.get();
|
|
536
|
+
const actorAtPath = owesFirstSync && this.isActorAtPath(machinePath, restoredRoute);
|
|
537
|
+
const actorAtLocation = actorAtPath && restoredRoute != null && sameQuery(restoredRoute, search);
|
|
538
|
+
if (owesFirstSync && restoredRoute && isAtInitialLocation(this.actor.initialRoute, machinePath, search) && restoredRoute !== this.actor.initialRoute && !actorAtLocation && !(actorAtPath && carriesNoOwnSuffix(restoredRoute))) return this.pushUnderMount(restoredRoute);
|
|
539
|
+
if (owesFirstSync) {
|
|
540
|
+
if (actorAtLocation) {
|
|
541
|
+
this.lastSyncedPath = withSearch(machinePath, search);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
this.lastSyncedPath = null;
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
if (previous !== "outside-push-owed") return false;
|
|
548
|
+
if (!this.isEchoOfLastSync(machinePath, search)) return false;
|
|
549
|
+
if (!restoredRoute) return false;
|
|
550
|
+
return this.pushUnderMount(restoredRoute);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Writes the route of the actor to a router that just came under the mount.
|
|
554
|
+
*
|
|
555
|
+
* The method holds `isProcessingNavigation` across the push, because
|
|
556
|
+
* `navigateRouter` makes the router call `syncActorFromRouter` again inside it.
|
|
557
|
+
*
|
|
558
|
+
* A re-entrant call pushes nothing inside another push, and the memory of a
|
|
559
|
+
* suppressed push DOES survive that case: the outer push is still in flight, and a
|
|
560
|
+
* clear here would throw the remembered route away with nothing left to replay it.
|
|
561
|
+
*
|
|
562
|
+
* @param route - The raw value of the actor route.
|
|
563
|
+
* @returns `true` when the method wrote the location, so that the caller returns and
|
|
564
|
+
* sends no event. `false` when the route resolves to no concrete path, so that the
|
|
565
|
+
* caller drives the actor from the location instead.
|
|
566
|
+
*/
|
|
567
|
+
pushUnderMount(route) {
|
|
568
|
+
if (this.isProcessingNavigation) return true;
|
|
569
|
+
const resolved = this.resolveNavigationPath(route);
|
|
570
|
+
if (resolved === null) {
|
|
571
|
+
this.lastSyncedPath = null;
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
this.pushHoldingNavigation(route, resolved);
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Tests a route of the actor against the last synchronized location.
|
|
579
|
+
*
|
|
580
|
+
* The exact string test covers a stateId, for example `"#home"`, which
|
|
581
|
+
* `pushResolvedRoute` writes to the field when it cannot resolve a path. The query
|
|
582
|
+
* test covers a location that carries a query: the order of the keys of a query is
|
|
583
|
+
* not significant, so `?b=2&a=1` and `?a=1&b=2` are the same location, and the
|
|
584
|
+
* bridge must push neither of them back.
|
|
585
|
+
*
|
|
586
|
+
* The FRAGMENT counts in this direction, and the inbound test ignores it. The two
|
|
587
|
+
* are different questions. `navigateRouter` writes the whole value that the actor
|
|
588
|
+
* gave — `buildRouteUrl` appends `context.hash` — so `"/docs"` and `"/docs#intro"`
|
|
589
|
+
* are two locations to write, and a test that read them as one left the fragment out
|
|
590
|
+
* of the address bar for ever. `isEchoOfLastSync` compares against what the ROUTER
|
|
591
|
+
* reports, and `sanitizePathname` has removed the fragment from that already.
|
|
592
|
+
*/
|
|
593
|
+
testEchoOfLastPush(route) {
|
|
594
|
+
if (route === this.lastActorRoute) return { isEcho: true };
|
|
595
|
+
const location = this.lastSyncedPath;
|
|
596
|
+
if (location === null) return {
|
|
597
|
+
isEcho: false,
|
|
598
|
+
resolved: this.resolveNavigationPath(route)
|
|
599
|
+
};
|
|
600
|
+
if (sameLocation(route, location)) return { isEcho: true };
|
|
601
|
+
const resolved = this.resolveNavigationPath(route);
|
|
602
|
+
if (resolved !== null && sameLocation(resolved, location)) return { isEcho: true };
|
|
603
|
+
return {
|
|
604
|
+
isEcho: false,
|
|
605
|
+
resolved
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Resolves an actor route to its concrete URL path, and pushes that path to the
|
|
610
|
+
* router.
|
|
611
|
+
*
|
|
612
|
+
* lastSyncedPath must hold the concrete path, because the callback of the router
|
|
613
|
+
* fires with the concrete path. The two values therefore match, and
|
|
614
|
+
* syncActorFromRouter stops. A raw stateId in that field, for example "#home", does
|
|
615
|
+
* not match the sanitized path ("/") in the watcher.
|
|
616
|
+
*
|
|
617
|
+
* When resolveNavigationPath returns null, which happens for a parameterized
|
|
618
|
+
* pattern, for a wildcard pattern, and for an unknown id, the method skips the push
|
|
619
|
+
* completely: a push of the raw value writes a stateId, a `:param` pattern, or a
|
|
620
|
+
* literal `*` into the browser URL. The method still writes the raw route to
|
|
621
|
+
* lastActorRoute, and it leaves lastSyncedPath as it stands, because that field
|
|
622
|
+
* records a LOCATION and no location changed. Therefore the dedup guard fires
|
|
623
|
+
* correctly on the next identical value of the atom.
|
|
624
|
+
*
|
|
625
|
+
* @param route - The raw value of the actor route.
|
|
626
|
+
* @param resolved - The concrete path of that route. The default resolves it here. A
|
|
627
|
+
* caller that resolved it already passes it, so that `resolveNavigationPath` runs
|
|
628
|
+
* one time: the method is `protected`, and a subclass can override it.
|
|
629
|
+
*/
|
|
630
|
+
pushResolvedRoute(route, resolved = this.resolveNavigationPath(route)) {
|
|
631
|
+
this.lastActorRoute = route;
|
|
632
|
+
if (resolved === null) return;
|
|
633
|
+
this.lastSyncedPath = resolved;
|
|
634
|
+
this.navigateRouter(joinBasePath(this.mount.path, resolved));
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Writes the actor state when the location of the router changes.
|
|
638
|
+
*
|
|
639
|
+
* **A known path:** the method sends a `play.route` event to the actor, with the
|
|
640
|
+
* stateId of the match, the params, and the query. The `isProcessingNavigation` flag
|
|
641
|
+
* stops a circular update.
|
|
642
|
+
*
|
|
643
|
+
* **An unknown path, or a path with no entry in the map:** the method sends NO
|
|
644
|
+
* `play.route` event, and the actor state stays as it is. It corrects the browser URL
|
|
645
|
+
* instead, with a `navigateRouter(actor.currentRoute.get())` call. The URL therefore
|
|
646
|
+
* follows the actor state, also when the user types an invalid path in the address
|
|
647
|
+
* bar, or when the code pushes such a path during a session. The method writes the
|
|
648
|
+
* resolved concrete path to `lastSyncedPath` before the `navigateRouter` call.
|
|
649
|
+
* Therefore the callback of the router for that navigation stops at the guard of the
|
|
650
|
+
* echo suppression, and it sends no false event.
|
|
651
|
+
*/
|
|
652
|
+
syncActorFromRouter(pathname, search) {
|
|
653
|
+
if (this.hasConnectedOnce && !this.isConnected) return;
|
|
654
|
+
if (typeof pathname !== "string") return;
|
|
655
|
+
const sanitized = sanitizePathname(pathname);
|
|
656
|
+
if (sanitized === null) return;
|
|
657
|
+
const machinePath = stripBasePath(sanitized, this.mount.path);
|
|
658
|
+
if (machinePath === null) {
|
|
659
|
+
this.markOutside();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (this.takeReturnUnderMount(machinePath, search)) return;
|
|
663
|
+
this.hasSynchronizedUnderMount = true;
|
|
664
|
+
if (this.isEchoOfLastSync(machinePath, search)) return;
|
|
665
|
+
if (this.isProcessingNavigation) return;
|
|
666
|
+
this.isProcessingNavigation = true;
|
|
667
|
+
try {
|
|
668
|
+
const nextRoute = buildPlayRouteEvent({
|
|
669
|
+
pathname: machinePath,
|
|
670
|
+
search,
|
|
671
|
+
match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId))
|
|
672
|
+
});
|
|
673
|
+
if (!nextRoute) {
|
|
674
|
+
const currentActorRoute = this.actor.currentRoute.get();
|
|
675
|
+
if (currentActorRoute) this.pushResolvedRoute(currentActorRoute);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
this.lastSyncedPath = withSearch(nextRoute.pathname, search);
|
|
679
|
+
this.lastActorRoute = null;
|
|
680
|
+
this.actor.send(nextRoute.event);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (error instanceof InvalidRoutePatternError) throw error;
|
|
683
|
+
throw new RouterSyncError("Failed to sync actor state from router location.", { cause: error });
|
|
684
|
+
} finally {
|
|
685
|
+
this.isProcessingNavigation = false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Reads the path parameters of a URL, with the URLPattern API.
|
|
690
|
+
*
|
|
691
|
+
* The API is always present: `@xmachines/play-url` uses the native URLPattern of the
|
|
692
|
+
* runtime, and it falls back to `urlpattern-polyfill`, which it carries as an ordinary
|
|
693
|
+
* dependency. A consumer therefore loads nothing, whatever the runtime.
|
|
694
|
+
*
|
|
695
|
+
* @param pathname - The real URL path, for example '/profile/john'
|
|
696
|
+
* @param stateId - The stateId of the match, for the lookup of the route pattern
|
|
697
|
+
* @returns The path parameters of the read, or an empty object when nothing matches
|
|
698
|
+
* @throws {InvalidRoutePatternError} When URLPattern cannot compile the route pattern
|
|
699
|
+
*/
|
|
700
|
+
extractParams(pathname, stateId) {
|
|
701
|
+
const pattern = this.routeMap.getPathByStateId(stateId);
|
|
702
|
+
if (!pattern) return {};
|
|
703
|
+
return extractRouteParams(pathname, pattern, this.compileOptions);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Resolves a value of an actor route to a concrete URL path for a navigation.
|
|
707
|
+
*
|
|
708
|
+
* A bridge that receives a raw `actor.currentRoute` value in `navigateRouter` calls
|
|
709
|
+
* this method to convert a stateId, with a `#` prefix or without one, into a path.
|
|
710
|
+
* The method returns `null` when a navigation is not possible:
|
|
711
|
+
* - an unknown stateId, with no entry in the route map
|
|
712
|
+
* - a parameterized pattern or a wildcard pattern, for example `/profile/:id` or
|
|
713
|
+
* `/docs/*`, because no concrete value is available for a real URL
|
|
714
|
+
* - a string that is no path and no known stateId
|
|
715
|
+
*
|
|
716
|
+
* A LITERAL route of the map comes back with each escape resolved: `/tags/c\+\+` gives
|
|
717
|
+
* the path `/tags/c++`, which is the path that the route matches.
|
|
718
|
+
*
|
|
719
|
+
* A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
|
|
720
|
+
* through `lookupPathByStateId`. Therefore a route map of your own that holds one
|
|
721
|
+
* form only still resolves the path.
|
|
722
|
+
*
|
|
723
|
+
* The result is MACHINE-side: it carries no `basePath`, because it is also the
|
|
724
|
+
* value that `lastSyncedPath` stores and that the code compares against an actor
|
|
725
|
+
* route. The code adds the prefix one time, at the `navigateRouter()` call in
|
|
726
|
+
* `pushResolvedRoute`.
|
|
727
|
+
*
|
|
728
|
+
* @param route - The raw value of the actor route: a stateId, a stateId with a `#`, or a concrete path
|
|
729
|
+
* @returns The concrete machine-side URL path, or `null` when the bridge must skip the navigation
|
|
730
|
+
*/
|
|
731
|
+
resolveNavigationPath(route) {
|
|
732
|
+
const mapped = this.lookupPathByStateId(route);
|
|
733
|
+
const path = mapped ?? (route.startsWith("/") ? route : null);
|
|
734
|
+
if (path === null) return null;
|
|
735
|
+
if (mapped != null) return parsePattern(path, this.compileOptions).literalPath;
|
|
736
|
+
const { pathname, suffix } = splitLocation(path);
|
|
737
|
+
if (pathname.includes("\\")) {
|
|
738
|
+
const literal = parsePattern(pathname, this.compileOptions).literalPath;
|
|
739
|
+
return literal === null ? null : `${literal}${suffix}`;
|
|
740
|
+
}
|
|
741
|
+
if (holdsUnsubstitutedParam(pathname)) return null;
|
|
742
|
+
return path;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Tells you whether the actor already stands on the PATH of a location of the router.
|
|
746
|
+
*
|
|
747
|
+
* The `currentRoute` of the actor can be a stateId, for example `"#app.home"`, while a
|
|
748
|
+
* location of the router is a URL path, for example `"/home"`. Therefore the method
|
|
749
|
+
* resolves the path to a stateId before it compares. Without this step, a false "the
|
|
750
|
+
* two are different" starts a synchronization that nothing needs — and, in
|
|
751
|
+
* `takeReturnUnderMount`, it reads a location that the HOST chose as a restore of the
|
|
752
|
+
* actor and writes over it.
|
|
753
|
+
*
|
|
754
|
+
* The actor is on the path in three cases: the stateId of the match IS the actor
|
|
755
|
+
* route, the path of the match IS the actor route, or the actor route is a stateId
|
|
756
|
+
* that resolves to the same registered path as the stateId of the match. Each lookup
|
|
757
|
+
* goes through {@link lookupPathByStateId}, which tries the form `"#stateId"` and also
|
|
758
|
+
* the bare form `"stateId"`. Therefore a route map with `"#about"` recognizes an actor
|
|
759
|
+
* route of `"about"`, and the opposite also works, and this is correct for a
|
|
760
|
+
* structural map of your own that holds one form.
|
|
761
|
+
*
|
|
762
|
+
* An actor route with a concrete path, which starts with `"/"`, goes to a direct
|
|
763
|
+
* comparison of the pathname against `machinePath`. That comparison runs BEFORE the
|
|
764
|
+
* map, because a location the map does not hold is still a location the actor can
|
|
765
|
+
* stand on.
|
|
766
|
+
*
|
|
767
|
+
* The QUERY is a question of its own, and {@link sameQuery} answers it. Each caller
|
|
768
|
+
* asks BOTH, and the two answers do different work. "The two sides are in step" needs
|
|
769
|
+
* the path AND the query, so a router that carries the only query is a deep link that
|
|
770
|
+
* the actor has to follow — for a stateId route as much as for a path route. The
|
|
771
|
+
* RESTORE needs the path, beside `carriesNoOwnSuffix`: an actor that stands here and
|
|
772
|
+
* carries no query and no fragment of its own has nothing to put in the address bar,
|
|
773
|
+
* and a push would only rewrite the location the user opened.
|
|
774
|
+
*
|
|
775
|
+
* A restore also needs the router at the initial URL, and {@link isAtInitialLocation}
|
|
776
|
+
* compares the query there too. An actor at `/?ref=nav` therefore loses against a
|
|
777
|
+
* router at `/?other=1`: the user opened a URL that the machine does not start at,
|
|
778
|
+
* and that is a deep link (issue #18).
|
|
779
|
+
*
|
|
780
|
+
* Each lookup runs ONE time, and a caller that needs both answers calls this method
|
|
781
|
+
* one time too. `getPathByStateId` belongs to the route map: a map of a consumer can
|
|
782
|
+
* answer differently on a second call, and `RouteMap` pays a cache lookup for each of
|
|
783
|
+
* them.
|
|
784
|
+
*
|
|
785
|
+
* @param machinePath - The machine half of the location of the router, with no query.
|
|
786
|
+
* @param actorRoute - The raw value of the actor route. It CAN carry a query.
|
|
787
|
+
*/
|
|
788
|
+
isActorAtPath(machinePath, actorRoute) {
|
|
789
|
+
if (actorRoute == null) return false;
|
|
790
|
+
const { pathname: actorPath } = splitLocation(actorRoute);
|
|
791
|
+
if (machinePath === ((actorPath.includes("\\") ? parsePattern(actorPath, this.compileOptions).literalPath : null) ?? actorPath)) return true;
|
|
792
|
+
const resolvedStateId = this.routeMap.getStateIdByPath(machinePath);
|
|
793
|
+
if (resolvedStateId === null || resolvedStateId === void 0) return false;
|
|
794
|
+
if (resolvedStateId === actorRoute) return true;
|
|
795
|
+
if (actorRoute.startsWith("/")) return false;
|
|
796
|
+
const actorStateIdPath = this.lookupPathByStateId(actorRoute);
|
|
797
|
+
return actorStateIdPath != null && actorStateIdPath === this.lookupPathByStateId(resolvedStateId);
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Returns the path of the route map for a value of an actor route, and it tries both
|
|
801
|
+
* forms of a stateId.
|
|
802
|
+
*
|
|
803
|
+
* The actor can emit `"#stateId"` while a route map of your own holds the bare form
|
|
804
|
+
* `"stateId"`, and also the opposite. `RouteMap` makes the canonical form
|
|
805
|
+
* internally, but the constructor accepts every structural
|
|
806
|
+
* `{ getStateIdByPath, getPathByStateId }` object: a plain test object and a map of
|
|
807
|
+
* one adapter are legal, and the documentation says so. Therefore this method tries
|
|
808
|
+
* both forms: the raw form first, then the form without the `#`, then the form with
|
|
809
|
+
* the `#`.
|
|
810
|
+
*
|
|
811
|
+
* @param route - The raw value of the actor route: a stateId, with a `#` prefix or without one
|
|
812
|
+
* @returns The path of the map, or `null` or `undefined` when no form is registered
|
|
813
|
+
*/
|
|
814
|
+
lookupPathByStateId(route) {
|
|
815
|
+
const direct = this.routeMap.getPathByStateId(route);
|
|
816
|
+
if (direct != null) return direct;
|
|
817
|
+
const alternate = route.startsWith("#") ? route.slice(1) : `#${route}`;
|
|
818
|
+
return this.routeMap.getPathByStateId(alternate);
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Reads the query parameters of a URL search string.
|
|
822
|
+
*
|
|
823
|
+
* @param search - The URL search string, for example '?tab=security&page=1'
|
|
824
|
+
* @returns The query parameters of the read, or an empty object
|
|
825
|
+
*/
|
|
826
|
+
extractQuery(search) {
|
|
827
|
+
return extractQuery(search);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* True when a location of the router is the location that this bridge pushed.
|
|
831
|
+
*
|
|
832
|
+
* `lastSyncedPath` holds the value of `actor.currentRoute`, and that value carries
|
|
833
|
+
* the query string of `context.query`, for example `"/dates?trip=one-way"`. A router
|
|
834
|
+
* callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
|
|
835
|
+
* removes the query. A comparison of the pathname alone therefore misses for every
|
|
836
|
+
* route with a query, and the push of this bridge comes back as a `play.route` event
|
|
837
|
+
* of its own navigation. That echo is not only wasted work: the bridge sends a
|
|
838
|
+
* `play.route` for a location that the actor stands on already, so every entry action
|
|
839
|
+
* of that state runs a second time, and a guard that refuses the repeat moves the
|
|
840
|
+
* actor to a state that no user asked for.
|
|
841
|
+
*
|
|
842
|
+
* The query is compared as a MAP, and not as a string: a framework can re-serialize
|
|
843
|
+
* a search string in another order, and `URLSearchParams` also accepts the form with
|
|
844
|
+
* a leading `?` and the form without one.
|
|
845
|
+
*
|
|
846
|
+
* @param sanitized - The clean pathname of the router callback, with no query.
|
|
847
|
+
* @param search - The search string of the router callback, when it has one.
|
|
848
|
+
* @returns True when the location is the echo of the last push of this bridge.
|
|
849
|
+
*/
|
|
850
|
+
isEchoOfLastSync(sanitized, search) {
|
|
851
|
+
const last = this.lastSyncedPath;
|
|
852
|
+
if (last === null) return false;
|
|
853
|
+
if (sanitized === last && (search ?? "") === "") return true;
|
|
854
|
+
const { pathname: lastPath, query: lastQuery } = splitLocation(last);
|
|
855
|
+
if (lastPath !== sanitized) return false;
|
|
856
|
+
return sortedQuery(lastQuery) === sortedQuery(search ?? "");
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Cleans a raw URL pathname of the router, and checks it.
|
|
860
|
+
*
|
|
861
|
+
* The method applies the limit of the path length, which is 2048 characters. It
|
|
862
|
+
* removes a query string and a fragment from the segment of the pathname, and it
|
|
863
|
+
* normalizes each sequence of slashes.
|
|
864
|
+
*
|
|
865
|
+
* An implementation that passes around `syncActorFromRouter()`, for example with a
|
|
866
|
+
* reactive watcher of its framework that receives a route object of a parse before,
|
|
867
|
+
* MUST call this method before it gives the path to a lookup in the route map.
|
|
868
|
+
* `syncActorFromRouter()` calls the method internally. Therefore a bridge that uses
|
|
869
|
+
* that method calls `sanitizePath` never itself.
|
|
870
|
+
*
|
|
871
|
+
* @param pathname - The raw URL pathname of the framework router.
|
|
872
|
+
* @returns The clean pathname, or `null` when the path is invalid or too long.
|
|
873
|
+
*/
|
|
874
|
+
sanitizePath(pathname) {
|
|
875
|
+
return sanitizePathname(pathname);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Returns the current pathname of the router at the moment of `connect()`.
|
|
879
|
+
*
|
|
880
|
+
* `connect()` calls this method one time, for the first synchronization from the URL
|
|
881
|
+
* to the actor. router.subscribe() fires on a *later* navigation event only. It does
|
|
882
|
+
* not repeat the location that the router loaded before. A subclass that can read the
|
|
883
|
+
* current location of its router synchronously, for example with
|
|
884
|
+
* `router.state.location.pathname`, overrides this method. A deep link and a direct
|
|
885
|
+
* URL then drive the actor to the correct state, and they leave it not at the default
|
|
886
|
+
* of its machine.
|
|
887
|
+
*
|
|
888
|
+
* The meaning of each return value:
|
|
889
|
+
* - `string` → the router has a current path, and the base `connect()` writes the actor state from the router
|
|
890
|
+
* - `null` → the router is active, but it has no current path yet, and the base `connect()` writes the router from the actor
|
|
891
|
+
* - `undefined` → the adapter does the first synchronization itself, and the base `connect()` does nothing
|
|
892
|
+
*
|
|
893
|
+
* The default value is `undefined`. Therefore a bridge without this hook keeps its
|
|
894
|
+
* earlier behavior.
|
|
895
|
+
*/
|
|
896
|
+
getInitialRouterPath() {}
|
|
897
|
+
/**
|
|
898
|
+
* Returns the current search string of the router at the moment of `connect()`.
|
|
899
|
+
*
|
|
900
|
+
* This method is the pair of `getInitialRouterPath()`. `connect()` calls it one time,
|
|
901
|
+
* to give the query string of the first URL to `syncActorFromRouter()`. It returns
|
|
902
|
+
* `undefined` when the current URL of the router has no search string, and also when
|
|
903
|
+
* the subclass overrides the method not. `syncActorFromRouter` then makes an empty
|
|
904
|
+
* `query: {}` in the event.
|
|
905
|
+
*
|
|
906
|
+
* A subclass that overrides `getInitialRouterPath()` and has a query string
|
|
907
|
+
* overrides this method too.
|
|
908
|
+
*
|
|
909
|
+
* @returns The URL search string, for example `"?tab=security"`, or `undefined` when it is not available.
|
|
910
|
+
*/
|
|
911
|
+
getInitialRouterSearch() {}
|
|
912
|
+
};
|
|
1498
913
|
/**
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
914
|
+
* True when the query of an actor route and the query of the router are the same query.
|
|
915
|
+
*
|
|
916
|
+
* `isActorAtPath` answers where the actor stands, and this function answers whether the
|
|
917
|
+
* two sides agree about the query there. The order of the keys is not significant, which
|
|
918
|
+
* is what `isEchoOfLastSync` says for the same pair of values.
|
|
919
|
+
*
|
|
920
|
+
* A query that DIFFERS answers `false`, and it must: the first synchronization gives the
|
|
921
|
+
* router the priority, so a location with another query is a deep link that the actor has
|
|
922
|
+
* to follow. An ABSENT query of the actor against a query of the router is such a
|
|
923
|
+
* difference — without this, a deep link of `/settings?ref=nav` reached an actor that
|
|
924
|
+
* stood at `/settings` never, and `context.query` stayed empty for the session while
|
|
925
|
+
* `lastSyncedPath` recorded the query and suppressed every later report of it. A stateId
|
|
926
|
+
* carries no query of its own, so it stands at a location with one never.
|
|
927
|
+
*
|
|
928
|
+
* @param actorRoute - The raw value of the actor route. It CAN carry a query.
|
|
929
|
+
* @param search - The query of the router, or `undefined` when it has none.
|
|
930
|
+
*/
|
|
1516
931
|
function sameQuery(actorRoute, search) {
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
// read as "the same". An actor that carries `/#intro` against a router at `/` is the
|
|
1521
|
-
// restore that puts the fragment in the address bar, and a `true` here swallowed it:
|
|
1522
|
-
// the bridge recorded the location, sent nothing, and the fragment reached the URL on
|
|
1523
|
-
// no later navigation either.
|
|
1524
|
-
if (fragment !== "")
|
|
1525
|
-
return false;
|
|
1526
|
-
return sortedQuery(actorQuery) === sortedQuery(search ?? "");
|
|
932
|
+
const { query: actorQuery, fragment } = splitLocation(actorRoute);
|
|
933
|
+
if (fragment !== "") return false;
|
|
934
|
+
return sortedQuery(actorQuery) === sortedQuery(search ?? "");
|
|
1527
935
|
}
|
|
1528
936
|
/**
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
937
|
+
* True when an actor route carries no query and no fragment of its own.
|
|
938
|
+
*
|
|
939
|
+
* The RESTORE branch asks this beside {@link RouterBridgeBase.isActorAtPath}. An actor
|
|
940
|
+
* that stands on the path of the router and answers `true` here restores nothing: the
|
|
941
|
+
* address bar already shows its place, and a push writes the same path back and drops
|
|
942
|
+
* whatever the router carried beside it.
|
|
943
|
+
*
|
|
944
|
+
* A stateId answers `true`, because a stateId carries no query and no fragment. The
|
|
945
|
+
* leading `#` of `"#home"` is no fragment, which is what {@link splitLocation} says at
|
|
946
|
+
* index 0.
|
|
947
|
+
*
|
|
948
|
+
* @param actorRoute - The raw value of the actor route.
|
|
949
|
+
*/
|
|
1542
950
|
function carriesNoOwnSuffix(actorRoute) {
|
|
1543
|
-
|
|
1544
|
-
|
|
951
|
+
const { query, fragment } = splitLocation(actorRoute);
|
|
952
|
+
return query === "" && fragment === "";
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* True when the router stands at the initial URL of the machine: at the path of
|
|
956
|
+
* `initialRoute` AND at its query.
|
|
957
|
+
*
|
|
958
|
+
* The restore test of `performInitialSync` and of `takeReturnUnderMount` asks this. It
|
|
959
|
+
* compared the path alone, so a router at `/?other=1` read as "at the initial URL `/`"
|
|
960
|
+
* and a restored actor at `/?ref=nav` won: the bridge pushed the query of the actor over
|
|
961
|
+
* the query that the user opened, and no `play.route` carried it (issue #18). A query
|
|
962
|
+
* that the machine did not put there is what a deep link looks like, so the router wins.
|
|
963
|
+
*
|
|
964
|
+
* The cost of the rule: after a reload, a query that the host or the browser added to the
|
|
965
|
+
* initial URL is a deep link too, and the actor loses its restored route to it.
|
|
966
|
+
*
|
|
967
|
+
* @param initialRoute - The initial route of the actor. `null` is no initial route.
|
|
968
|
+
* @param machinePath - The machine half of the location of the router, with no query.
|
|
969
|
+
* @param search - The query of the router.
|
|
970
|
+
*/
|
|
971
|
+
function isAtInitialLocation(initialRoute, machinePath, search) {
|
|
972
|
+
if (typeof initialRoute !== "string") return false;
|
|
973
|
+
return splitLocation(initialRoute).pathname === machinePath && sameQuery(initialRoute, search);
|
|
1545
974
|
}
|
|
1546
975
|
/** Joins a pathname and a query into one location value. An empty query adds nothing. */
|
|
1547
976
|
function withSearch(pathname, search) {
|
|
1548
|
-
|
|
1549
|
-
|
|
977
|
+
const query = (search ?? "").replace(/^\?/, "");
|
|
978
|
+
return query === "" ? pathname : `${pathname}?${query}`;
|
|
1550
979
|
}
|
|
1551
980
|
/**
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
981
|
+
* The canonical form of a query string, for a comparison that ignores the order:
|
|
982
|
+
* each `key=value` pair, sorted, joined with `&`. An empty search gives `""`.
|
|
983
|
+
*
|
|
984
|
+
* Each name AND each value are escaped, because a raw join ALIASES: with "=" and "&"
|
|
985
|
+
* as literal separators, `?a=1&b=2` and `?a=1%26b%3D2` gave one canonical form for two
|
|
986
|
+
* different locations. `isEchoOfLastSync` then read a real navigation as the echo of
|
|
987
|
+
* its own push, so the actor never heard of the move and the URL and the actor state
|
|
988
|
+
* diverged. `mountKey` of `provider-lifecycle.ts` escapes for the same reason.
|
|
989
|
+
*
|
|
990
|
+
* `toSorted()` and not `sort()`: an in-place sort is safe here, because the array is a
|
|
991
|
+
* fresh one of `map()` and the mutation escapes nowhere. It is still the second spelling
|
|
992
|
+
* of one operation. `mountKey` of `provider-lifecycle.ts` sorts this same shared source
|
|
993
|
+
* with `toSorted()`, so this site uses it too.
|
|
994
|
+
*
|
|
995
|
+
* The copy costs nothing that a profile can find. `resolveViewMeta` of play-xstate holds
|
|
996
|
+
* the measurement for the same question.
|
|
997
|
+
*/
|
|
1569
998
|
function sortedQuery(search) {
|
|
1570
|
-
|
|
1571
|
-
return pairs.toSorted().join("&");
|
|
999
|
+
return [...new URLSearchParams(search).entries()].map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).toSorted().join("&");
|
|
1572
1000
|
}
|
|
1573
|
-
function noopCleanup() {
|
|
1001
|
+
function noopCleanup() {}
|
|
1574
1002
|
/**
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1003
|
+
* Watches the route atom of the actor.
|
|
1004
|
+
*
|
|
1005
|
+
* The bridge wraps the read in NOTHING. A subscription evaluates a computed atom inside
|
|
1006
|
+
* the `set` of the actor state, so a throw from the derivation would unwind into
|
|
1007
|
+
* `actor.send` and break the transition for every other observer — and `currentRoute`
|
|
1008
|
+
* therefore contains its own failures, reports them through `onError` of the actor, and
|
|
1009
|
+
* keeps its last route. Every subscriber benefits from that rule, and not this bridge
|
|
1010
|
+
* alone.
|
|
1011
|
+
*/
|
|
1584
1012
|
function createRouteWatcher(atom, onRoute) {
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
},
|
|
1600
|
-
};
|
|
1601
|
-
watcher.watch(atom);
|
|
1602
|
-
return watcher;
|
|
1013
|
+
let cleanup = noopCleanup;
|
|
1014
|
+
const watcher = {
|
|
1015
|
+
watch(nextAtom) {
|
|
1016
|
+
watcher.unwatch();
|
|
1017
|
+
cleanup = watchAtom(nextAtom, onRoute);
|
|
1018
|
+
},
|
|
1019
|
+
unwatch() {
|
|
1020
|
+
const release = cleanup;
|
|
1021
|
+
cleanup = noopCleanup;
|
|
1022
|
+
release();
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
watcher.watch(atom);
|
|
1026
|
+
return watcher;
|
|
1603
1027
|
}
|
|
1604
1028
|
/**
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1029
|
+
* True when two machine-side locations are the same place.
|
|
1030
|
+
*
|
|
1031
|
+
* The order of the keys of a query is not significant, so `?b=2&a=1` and `?a=1&b=2` are
|
|
1032
|
+
* one location. The FRAGMENT counts: `navigateRouter` writes the whole value that the
|
|
1033
|
+
* actor gave — `buildRouteUrl` appends `context.hash` — so `/docs` and `/docs#intro` are
|
|
1034
|
+
* two locations to write, and a test that read them as one left the fragment out of the
|
|
1035
|
+
* address bar for ever.
|
|
1036
|
+
*/
|
|
1613
1037
|
function sameLocation(a, b) {
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
return false;
|
|
1620
|
-
return sortedQuery(aQuery) === sortedQuery(bQuery);
|
|
1038
|
+
const { pathname: aPath, query: aQuery, fragment: aFragment } = splitLocation(a);
|
|
1039
|
+
const { pathname: bPath, query: bQuery, fragment: bFragment } = splitLocation(b);
|
|
1040
|
+
if (aPath !== bPath) return false;
|
|
1041
|
+
if (aFragment !== bFragment) return false;
|
|
1042
|
+
return sortedQuery(aQuery) === sortedQuery(bQuery);
|
|
1621
1043
|
}
|
|
1044
|
+
//#endregion
|
|
1045
|
+
export { RouterBridgeBase };
|
|
1046
|
+
|
|
1622
1047
|
//# sourceMappingURL=router-bridge-base.js.map
|