@@ -18,9 +18,7 @@ import {
1818import { createRequestInit } from "./data" ;
1919import type { AssetsManifest , EntryContext } from "./entry" ;
2020import { escapeHtml } from "./markup" ;
21- import type { RouteModule , RouteModules } from "./routeModules" ;
2221import invariant from "./invariant" ;
23- import type { EntryRoute } from "./routes" ;
2422
2523export const SingleFetchRedirectSymbol = Symbol ( "SingleFetchRedirect" ) ;
2624
@@ -32,15 +30,22 @@ export type SingleFetchRedirectResult = {
3230 replace : boolean ;
3331} ;
3432
33+ // Shared/serializable type used by both turbo-stream and RSC implementations
34+ type DecodedSingleFetchResults =
35+ | { routes : { [ key : string ] : SingleFetchResult } }
36+ | { redirect : SingleFetchRedirectResult } ;
37+
38+ // This and SingleFetchResults are only used over the wire, and are converted to
39+ // DecodedSingleFetchResults in `fetchAndDecode`. This way turbo-stream/RSC
40+ // can use the same `unwrapSingleFetchResult` implementation.
3541export type SingleFetchResult =
3642 | { data : unknown }
3743 | { error : unknown }
3844 | SingleFetchRedirectResult ;
3945
40- export type SingleFetchResults = {
41- [ key : string ] : SingleFetchResult ;
42- [ SingleFetchRedirectSymbol ] ?: SingleFetchRedirectResult ;
43- } ;
46+ export type SingleFetchResults =
47+ | { [ key : string ] : SingleFetchResult }
48+ | { [ SingleFetchRedirectSymbol ] : SingleFetchRedirectResult } ;
4449
4550interface StreamTransferProps {
4651 context : EntryContext ;
@@ -50,6 +55,16 @@ interface StreamTransferProps {
5055 nonce ?: string ;
5156}
5257
58+ // Some status codes are not permitted to have bodies, so we want to just
59+ // treat those as "no data" instead of throwing an exception:
60+ // https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx
61+ // https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content
62+ // https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content
63+ //
64+ // Note: 304 is not included here because the browser should fill those responses
65+ // with the cached body content.
66+ export const NO_BODY_STATUS_CODES = new Set ( [ 100 , 101 , 204 , 205 ] ) ;
67+
5368// StreamTransfer recursively renders down chunks of the `serverHandoffStream`
5469// into the client-side `streamController`
5570export function StreamTransfer ( {
@@ -245,12 +260,13 @@ async function singleFetchActionStrategy(
245260 let result = await handler ( async ( ) => {
246261 let url = singleFetchUrl ( request . url , basename ) ;
247262 let init = await createRequestInit ( request ) ;
248- let { data, status } = await fetchAndDecode ( url , init ) ;
249- actionStatus = status ;
250- return unwrapSingleFetchResult (
251- data as SingleFetchResult ,
263+ let { data, status } = await fetchAndDecode (
264+ url ,
265+ init ,
252266 actionMatch ! . route . id
253267 ) ;
268+ actionStatus = status ;
269+ return unwrapSingleFetchResult ( data , actionMatch ! . route . id ) ;
254270 } ) ;
255271 return result ;
256272 } ) ;
@@ -308,23 +324,17 @@ async function singleFetchLoaderNavigationStrategy(
308324 router : DataRouter ,
309325 basename : string | undefined
310326) {
311- // Track which routes need a server load - in case we need to tack on a
312- // `_routes` param
327+ // Track which routes need a server load for use in a `_routes` param
313328 let routesParams = new Set < string > ( ) ;
314329
315- // We only add `_routes` when one or more routes opts out of a load via
316- // `shouldRevalidate` or `clientLoader`
330+ // Only add `_routes` when at least 1 route opts out via `shouldRevalidate`/`clientLoader`
317331 let foundOptOutRoute = false ;
318332
319- // Deferreds for each route so we can be sure they've all loaded via
320- // `match.resolve()`, and a singular promise that can tell us all routes
321- // have been resolved
333+ // Deferreds per-route so we can be sure they've all loaded via `match.resolve()`
322334 let routeDfds = matches . map ( ( ) => createDeferred < void > ( ) ) ;
323- let routesLoadedPromise = Promise . all ( routeDfds . map ( ( d ) => d . promise ) ) ;
324335
325- // Deferred that we'll use for the call to the server that each match can
326- // await and parse out it's specific result
327- let singleFetchDfd = createDeferred < SingleFetchResults > ( ) ;
336+ // Deferred we'll use for the singleular call to the server
337+ let singleFetchDfd = createDeferred < DecodedSingleFetchResults > ( ) ;
328338
329339 // Base URL and RequestInit for calls to the server
330340 let url = stripIndexParam ( singleFetchUrl ( request . url , basename ) ) ;
@@ -339,6 +349,7 @@ async function singleFetchLoaderNavigationStrategy(
339349 routeDfds [ i ] . resolve ( ) ;
340350
341351 let manifestRoute = manifest . routes [ m . route . id ] ;
352+ invariant ( manifestRoute , "No manifest route found for dataStrategy" ) ;
342353
343354 let defaultShouldRevalidate =
344355 ! m . unstable_shouldRevalidateArgs ||
@@ -347,8 +358,7 @@ async function singleFetchLoaderNavigationStrategy(
347358 let shouldCall = m . unstable_shouldCallHandler ( defaultShouldRevalidate ) ;
348359
349360 if ( ! shouldCall ) {
350- // If this route opted out of revalidation, we don't want to include
351- // it in the single fetch .data request
361+ // If this route opted out, don't include in the .data request
352362 foundOptOutRoute ||=
353363 m . unstable_shouldRevalidateArgs != null && // This is a revalidation,
354364 manifestRoute ?. hasLoader === true && // for a route with a server loader,
@@ -358,7 +368,7 @@ async function singleFetchLoaderNavigationStrategy(
358368
359369 // When a route has a client loader, it opts out of the singular call and
360370 // calls it's server loader via `serverLoader()` using a `?_routes` param
361- if ( manifestRoute && manifestRoute . hasClientLoader ) {
371+ if ( manifestRoute . hasClientLoader ) {
362372 if ( manifestRoute . hasLoader ) {
363373 foundOptOutRoute = true ;
364374 }
@@ -385,7 +395,7 @@ async function singleFetchLoaderNavigationStrategy(
385395 try {
386396 let result = await handler ( async ( ) => {
387397 let data = await singleFetchDfd . promise ;
388- return unwrapSingleFetchResults ( data , m . route . id ) ;
398+ return unwrapSingleFetchResult ( data , m . route . id ) ;
389399 } ) ;
390400 results [ m . route . id ] = {
391401 type : "data" ,
@@ -402,7 +412,7 @@ async function singleFetchLoaderNavigationStrategy(
402412 ) ;
403413
404414 // Wait for all routes to resolve above before we make the HTTP call
405- await routesLoadedPromise ;
415+ await Promise . all ( routeDfds . map ( ( d ) => d . promise ) ) ;
406416
407417 // We can skip the server call:
408418 // - On initial hydration - only clientLoaders can pass through via `clientLoader.hydrate`
@@ -417,24 +427,18 @@ async function singleFetchLoaderNavigationStrategy(
417427 ) {
418428 singleFetchDfd . resolve ( { } ) ;
419429 } else {
420- try {
421- // When one or more routes have opted out, we add a _routes param to
422- // limit the loaders to those that have a server loader and did not
423- // opt out
424- if ( ssr && foundOptOutRoute && routesParams . size > 0 ) {
425- url . searchParams . set (
426- "_routes" ,
427- matches
428- . filter ( ( m ) => routesParams . has ( m . route . id ) )
429- . map ( ( m ) => m . route . id )
430- . join ( "," )
431- ) ;
432- }
430+ // When routes have opted out, add a `_routes` param to filter server loaders
431+ // Skipped in `ssr:false` because we expect to be loading static `.data` files
432+ if ( ssr && foundOptOutRoute && routesParams . size > 0 ) {
433+ let routes = [ ...routesParams . keys ( ) ] . join ( "," ) ;
434+ url . searchParams . set ( "_routes" , routes ) ;
435+ }
433436
437+ try {
434438 let data = await fetchAndDecode ( url , init ) ;
435- singleFetchDfd . resolve ( data . data as SingleFetchResults ) ;
439+ singleFetchDfd . resolve ( data . data ) ;
436440 } catch ( e ) {
437- singleFetchDfd . reject ( e as Error ) ;
441+ singleFetchDfd . reject ( e ) ;
438442 }
439443 }
440444
@@ -471,7 +475,7 @@ function fetchSingleLoader(
471475 let singleLoaderUrl = new URL ( url ) ;
472476 singleLoaderUrl . searchParams . set ( "_routes" , routeId ) ;
473477 let { data } = await fetchAndDecode ( singleLoaderUrl , init ) ;
474- return unwrapSingleFetchResults ( data as SingleFetchResults , routeId ) ;
478+ return unwrapSingleFetchResult ( data , routeId ) ;
475479 } ) ;
476480}
477481
@@ -520,8 +524,9 @@ export function singleFetchUrl(
520524
521525async function fetchAndDecode (
522526 url : URL ,
523- init : RequestInit
524- ) : Promise < { status : number ; data : unknown } > {
527+ init : RequestInit ,
528+ routeId ?: string
529+ ) : Promise < { status : number ; data : DecodedSingleFetchResults } > {
525530 let res = await fetch ( url , init ) ;
526531
527532 // If this 404'd without hitting the running server (most likely in a
@@ -530,27 +535,39 @@ async function fetchAndDecode(
530535 throw new ErrorResponseImpl ( 404 , "Not Found" , true ) ;
531536 }
532537
533- // some status codes are not permitted to have bodies, so we want to just
534- // treat those as "no data" instead of throwing an exception.
535- // 304 is not included here because the browser should fill those responses
536- // with the cached body content.
537- const NO_BODY_STATUS_CODES = new Set ( [ 100 , 101 , 204 , 205 ] ) ;
538538 if ( NO_BODY_STATUS_CODES . has ( res . status ) ) {
539- if ( ! init . method || init . method === "GET" ) {
540- // SingleFetchResults can just have no routeId keys which will result
541- // in no data for all routes
542- return { status : res . status , data : { } } ;
543- } else {
544- // SingleFetchResult is for a singular route and can specify no data
545- return { status : res . status , data : { data : undefined } } ;
539+ let routes : { [ key : string ] : SingleFetchResult } = { } ;
540+ if ( routeId ) {
541+ routes [ routeId ] = { data : undefined } ;
546542 }
543+ return {
544+ status : res . status ,
545+ data : { routes } ,
546+ } ;
547547 }
548548
549549 invariant ( res . body , "No response body to decode" ) ;
550550
551551 try {
552552 let decoded = await decodeViaTurboStream ( res . body , window ) ;
553- return { status : res . status , data : decoded . value } ;
553+ let data : DecodedSingleFetchResults ;
554+ if ( ! init . method || init . method === "GET" ) {
555+ let typed = decoded . value as SingleFetchResults ;
556+ if ( SingleFetchRedirectSymbol in typed ) {
557+ data = { redirect : typed [ SingleFetchRedirectSymbol ] } ;
558+ } else {
559+ data = { routes : typed } ;
560+ }
561+ } else {
562+ let typed = decoded . value as SingleFetchResult ;
563+ invariant ( routeId , "No routeId found for single fetch call decoding" ) ;
564+ if ( "redirect" in typed ) {
565+ data = { redirect : typed } ;
566+ } else {
567+ data = { routes : { [ routeId ] : typed } } ;
568+ }
569+ }
570+ return { status : res . status , data } ;
554571 } catch ( e ) {
555572 // Can't clone after consuming the body via turbo-stream so we can't
556573 // include the body here. In an ideal world we'd look for a turbo-stream
@@ -617,53 +634,50 @@ export function decodeViaTurboStream(
617634 } ) ;
618635}
619636
620- function unwrapSingleFetchResults (
621- results : SingleFetchResults ,
637+ function unwrapSingleFetchResult (
638+ result : DecodedSingleFetchResults ,
622639 routeId : string
623640) {
624- let redirect = results [ SingleFetchRedirectSymbol ] ;
625- if ( redirect ) {
626- return unwrapSingleFetchResult ( redirect , routeId ) ;
641+ if ( "redirect" in result ) {
642+ let {
643+ redirect : location ,
644+ revalidate,
645+ reload,
646+ replace,
647+ status,
648+ } = result . redirect ;
649+ throw redirect ( location , {
650+ status,
651+ headers : {
652+ // Three R's of redirecting (lol Veep)
653+ ...( revalidate ? { "X-Remix-Revalidate" : "yes" } : null ) ,
654+ ...( reload ? { "X-Remix-Reload-Document" : "yes" } : null ) ,
655+ ...( replace ? { "X-Remix-Replace" : "yes" } : null ) ,
656+ } ,
657+ } ) ;
627658 }
628659
629- return results [ routeId ] !== undefined
630- ? unwrapSingleFetchResult ( results [ routeId ] , routeId )
631- : null ;
632- }
633-
634- function unwrapSingleFetchResult ( result : SingleFetchResult , routeId : string ) {
635- if ( "error" in result ) {
636- throw result . error ;
637- } else if ( "redirect" in result ) {
638- let headers : Record < string , string > = { } ;
639- if ( result . revalidate ) {
640- headers [ "X-Remix-Revalidate" ] = "yes" ;
641- }
642- if ( result . reload ) {
643- headers [ "X-Remix-Reload-Document" ] = "yes" ;
644- }
645- if ( result . replace ) {
646- headers [ "X-Remix-Replace" ] = "yes" ;
647- }
648- throw redirect ( result . redirect , { status : result . status , headers } ) ;
649- } else if ( "data" in result ) {
650- return result . data ;
660+ let routeResult = result . routes [ routeId ] ;
661+ if ( "error" in routeResult ) {
662+ throw routeResult . error ;
663+ } else if ( "data" in routeResult ) {
664+ return routeResult . data ;
651665 } else {
652666 throw new Error ( `No response found for routeId "${ routeId } "` ) ;
653667 }
654668}
655669
656670function createDeferred < T = unknown > ( ) {
657671 let resolve : ( val ?: any ) => Promise < void > ;
658- let reject : ( error ?: Error ) => Promise < void > ;
672+ let reject : ( error ?: unknown ) => Promise < void > ;
659673 let promise = new Promise < T > ( ( res , rej ) => {
660674 resolve = async ( val : T ) => {
661675 res ( val ) ;
662676 try {
663677 await promise ;
664678 } catch ( e ) { }
665679 } ;
666- reject = async ( error ?: Error ) => {
680+ reject = async ( error ?: unknown ) => {
667681 rej ( error ) ;
668682 try {
669683 await promise ;
0 commit comments