@@ -188,27 +188,11 @@ function createRouterFromPayload({
188
188
undefined ,
189
189
false
190
190
) ,
191
- async patchRoutesOnNavigation ( { patch, path, signal } ) {
192
- let response = await fetch ( `${ path } .manifest` , { signal } ) ;
193
- if ( ! response . body || response . status < 200 || response . status >= 300 ) {
194
- throw new Error ( "Unable to fetch new route matches from the server" ) ;
191
+ async patchRoutesOnNavigation ( { path, signal } ) {
192
+ if ( discoveredPaths . has ( path ) ) {
193
+ return ;
195
194
}
196
-
197
- let payload = await decode ( response . body ) ;
198
- if ( payload . type !== "manifest" ) {
199
- throw new Error ( "Failed to patch routes on navigation" ) ;
200
- }
201
-
202
- // Without the `allowElementMutations` flag, this will no-op if the route
203
- // already exists so we can just call it for all returned matches
204
- payload . matches . forEach ( ( match , i ) =>
205
- patch ( payload . matches [ i - 1 ] ?. id ?? null , [
206
- createRouteFromServerManifest ( match ) ,
207
- ] )
208
- ) ;
209
- payload . patches . forEach ( ( p ) => {
210
- patch ( p . parentId ?? null , [ createRouteFromServerManifest ( p ) ] ) ;
211
- } ) ;
195
+ await fetchAndApplyManifestPatches ( [ path ] , decode , signal ) ;
212
196
} ,
213
197
// FIXME: Pass `build.ssr` and `build.basename` into this function
214
198
dataStrategy : getRSCSingleFetchDataStrategy (
@@ -389,9 +373,11 @@ function getFetchAndDecodeViaRSC(
389
373
export function RSCHydratedRouter ( {
390
374
decode,
391
375
payload,
376
+ routeDiscovery = "eager" ,
392
377
} : {
393
378
decode : DecodeServerResponseFunction ;
394
379
payload : ServerPayload ;
380
+ routeDiscovery ?: "eager" | "lazy" ;
395
381
} ) {
396
382
if ( payload . type !== "render" ) throw new Error ( "Invalid payload type" ) ;
397
383
@@ -410,6 +396,75 @@ export function RSCHydratedRouter({
410
396
}
411
397
} , [ ] ) ;
412
398
399
+ React . useEffect ( ( ) => {
400
+ if (
401
+ routeDiscovery === "lazy" ||
402
+ // @ts -expect-error - TS doesn't know about this yet
403
+ window . navigator ?. connection ?. saveData === true
404
+ ) {
405
+ return ;
406
+ }
407
+
408
+ // Register a link href for patching
409
+ function registerElement ( el : Element ) {
410
+ let path =
411
+ el . tagName === "FORM"
412
+ ? el . getAttribute ( "action" )
413
+ : el . getAttribute ( "href" ) ;
414
+ if ( ! path ) {
415
+ return ;
416
+ }
417
+ // optimization: use the already-parsed pathname from links
418
+ let pathname =
419
+ el . tagName === "A"
420
+ ? ( el as HTMLAnchorElement ) . pathname
421
+ : new URL ( path , window . location . origin ) . pathname ;
422
+ if ( ! discoveredPaths . has ( pathname ) ) {
423
+ nextPaths . add ( pathname ) ;
424
+ }
425
+ }
426
+
427
+ // Register and fetch patches for all initially-rendered links/forms
428
+ async function fetchPatches ( ) {
429
+ // re-check/update registered links
430
+ document
431
+ . querySelectorAll ( "a[data-discover], form[data-discover]" )
432
+ . forEach ( registerElement ) ;
433
+
434
+ let paths = Array . from ( nextPaths . keys ( ) ) . filter ( ( path ) => {
435
+ if ( discoveredPaths . has ( path ) ) {
436
+ nextPaths . delete ( path ) ;
437
+ return false ;
438
+ }
439
+ return true ;
440
+ } ) ;
441
+
442
+ if ( paths . length === 0 ) {
443
+ return ;
444
+ }
445
+
446
+ try {
447
+ await fetchAndApplyManifestPatches ( paths , decode ) ;
448
+ } catch ( e ) {
449
+ console . error ( "Failed to fetch manifest patches" , e ) ;
450
+ }
451
+ }
452
+
453
+ let debouncedFetchPatches = debounce ( fetchPatches , 100 ) ;
454
+
455
+ // scan and fetch initial links
456
+ fetchPatches ( ) ;
457
+
458
+ let observer = new MutationObserver ( ( ) => debouncedFetchPatches ( ) ) ;
459
+
460
+ observer . observe ( document . documentElement , {
461
+ subtree : true ,
462
+ childList : true ,
463
+ attributes : true ,
464
+ attributeFilter : [ "data-discover" , "href" , "action" ] ,
465
+ } ) ;
466
+ } , [ routeDiscovery , decode ] ) ;
467
+
413
468
const frameworkContext : FrameworkContextObject = {
414
469
future : {
415
470
// These flags have no runtime impact so can always be false. If we add
@@ -556,3 +611,72 @@ function preventInvalidServerHandlerCall(
556
611
throw new ErrorResponseImpl ( 400 , "Bad Request" , new Error ( msg ) , true ) ;
557
612
}
558
613
}
614
+
615
+ // Currently rendered links that may need prefetching
616
+ const nextPaths = new Set < string > ( ) ;
617
+
618
+ // FIFO queue of previously discovered routes to prevent re-calling on
619
+ // subsequent navigations to the same path
620
+ const discoveredPathsMaxSize = 1000 ;
621
+ const discoveredPaths = new Set < string > ( ) ;
622
+
623
+ // 7.5k to come in under the ~8k limit for most browsers
624
+ // https://stackoverflow.com/a/417184
625
+ const URL_LIMIT = 7680 ;
626
+
627
+ async function fetchAndApplyManifestPatches (
628
+ paths : string [ ] ,
629
+ decode : DecodeServerResponseFunction ,
630
+ signal ?: AbortSignal
631
+ ) {
632
+ let basename = ( window . __router . basename ?? "" ) . replace ( / ^ \/ | \/ $ / g, "" ) ;
633
+ let url = new URL ( `${ basename } /.manifest` , window . location . origin ) ;
634
+ paths . sort ( ) . forEach ( ( path ) => url . searchParams . append ( "p" , path ) ) ;
635
+
636
+ // If the URL is nearing the ~8k limit on GET requests, skip this optimization
637
+ // step and just let discovery happen on link click. We also wipe out the
638
+ // nextPaths Set here so we can start filling it with fresh links
639
+ if ( url . toString ( ) . length > URL_LIMIT ) {
640
+ nextPaths . clear ( ) ;
641
+ return ;
642
+ }
643
+
644
+ let response = await fetch ( url , { signal } ) ;
645
+ if ( ! response . body || response . status < 200 || response . status >= 300 ) {
646
+ throw new Error ( "Unable to fetch new route matches from the server" ) ;
647
+ }
648
+
649
+ let payload = await decode ( response . body ) ;
650
+ if ( payload . type !== "manifest" ) {
651
+ throw new Error ( "Failed to patch routes" ) ;
652
+ }
653
+
654
+ // Track discovered paths so we don't have to fetch them again
655
+ paths . forEach ( ( p ) => addToFifoQueue ( p , discoveredPaths ) ) ;
656
+
657
+ // Without the `allowElementMutations` flag, this will no-op if the route
658
+ // already exists so we can just call it for all returned patches
659
+ payload . patches . forEach ( ( p ) => {
660
+ window . __router . patchRoutes ( p . parentId ?? null , [
661
+ createRouteFromServerManifest ( p ) ,
662
+ ] ) ;
663
+ } ) ;
664
+ }
665
+
666
+ function addToFifoQueue ( path : string , queue : Set < string > ) {
667
+ if ( queue . size >= discoveredPathsMaxSize ) {
668
+ let first = queue . values ( ) . next ( ) . value ;
669
+ queue . delete ( first ) ;
670
+ }
671
+ queue . add ( path ) ;
672
+ }
673
+
674
+ // Thanks Josh!
675
+ // https://www.joshwcomeau.com/snippets/javascript/debounce/
676
+ function debounce ( callback : ( ...args : unknown [ ] ) => unknown , wait : number ) {
677
+ let timeoutId : number | undefined ;
678
+ return ( ...args : unknown [ ] ) => {
679
+ window . clearTimeout ( timeoutId ) ;
680
+ timeoutId = window . setTimeout ( ( ) => callback ( ...args ) , wait ) ;
681
+ } ;
682
+ }
0 commit comments