From 8e2e25923898902275bd77684cf0a3ab5ce593b0 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 6 Dec 2023 12:09:10 -0800 Subject: [PATCH 01/50] wip --- packages/router/router.ts | 185 ++++++++++++++++++++++++++++---------- packages/router/utils.ts | 11 +++ 2 files changed, 148 insertions(+), 48 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 5992912a11..0dfc6855e7 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -13,6 +13,8 @@ import type { AgnosticDataRouteObject, AgnosticRouteObject, DataResult, + DataStrategyFunction, + DataStrategyFunctionArgs, DeferredData, DeferredResult, DetectErrorBoundaryFunction, @@ -370,6 +372,7 @@ export interface RouterInit { * @deprecated Use `mapRouteProperties` instead */ detectErrorBoundary?: DetectErrorBoundaryFunction; + dataStrategy?: DataStrategyFunction; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; hydrationData?: HydrationState; @@ -752,6 +755,8 @@ export function createRouter(init: RouterInit): Router { "You must provide a non-empty routes array to createRouter" ); + const dataStrategy = init.dataStrategy || defaultDataStrategy; + let mapRouteProperties: MapRoutePropertiesFunction; if (init.mapRouteProperties) { mapRouteProperties = init.mapRouteProperties; @@ -1567,7 +1572,7 @@ export function createRouter(init: RouterInit): Router { }), }; } else { - result = await callLoaderOrAction( + result = await loadOrMutateData( "action", request, actionMatch, @@ -1761,8 +1766,8 @@ export function createRouter(init: RouterInit): Router { ); } - let { results, loaderResults, fetcherResults } = - await callLoadersAndMaybeResolveData( + let { loaderResults, fetcherResults } = + await loadDataAndMaybeResolveDeferred( state.matches, matches, matchesToLoad, @@ -1786,7 +1791,7 @@ export function createRouter(init: RouterInit): Router { revalidatingFetchers.forEach((rf) => fetchControllers.delete(rf.key)); // If any loaders returned a redirect Response, start a new REPLACE navigation - let redirect = findRedirect(results); + let redirect = findRedirect([...loaderResults, ...fetcherResults]); if (redirect) { if (redirect.idx >= matchesToLoad.length) { // If this redirect came from a fetcher make sure we mark it in @@ -1961,7 +1966,7 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let actionResult = await callLoaderOrAction( + let actionResult = await loadOrMutateData( "action", fetchRequest, match, @@ -2086,8 +2091,8 @@ export function createRouter(init: RouterInit): Router { abortPendingFetchRevalidations ); - let { results, loaderResults, fetcherResults } = - await callLoadersAndMaybeResolveData( + let { loaderResults, fetcherResults } = + await loadDataAndMaybeResolveDeferred( state.matches, matches, matchesToLoad, @@ -2108,7 +2113,7 @@ export function createRouter(init: RouterInit): Router { fetchControllers.delete(key); revalidatingFetchers.forEach((r) => fetchControllers.delete(r.key)); - let redirect = findRedirect(results); + let redirect = findRedirect([...loaderResults, ...fetcherResults]); if (redirect) { if (redirect.idx >= matchesToLoad.length) { // If this redirect came from a fetcher make sure we mark it in @@ -2206,7 +2211,7 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let result: DataResult = await callLoaderOrAction( + let result: DataResult = await loadOrMutateData( "loader", fetchRequest, match, @@ -2391,52 +2396,126 @@ export function createRouter(init: RouterInit): Router { } } - async function callLoadersAndMaybeResolveData( - currentMatches: AgnosticDataRouteMatch[], + async function loadOrMutateData( + type: "loader" | "action", + request: Request, + match: AgnosticDataRouteMatch, matches: AgnosticDataRouteMatch[], - matchesToLoad: AgnosticDataRouteMatch[], - fetchersToLoad: RevalidatingFetcher[], - request: Request - ) { - // Call all navigation loaders and revalidating fetcher loaders in parallel, - // then slice off the results into separate arrays so we can handle them - // accordingly - let results = await Promise.all([ - ...matchesToLoad.map((match) => - callLoaderOrAction( - "loader", + manifest: RouteManifest, + mapRouteProperties: MapRoutePropertiesFunction, + basename: string, + v7_relativeSplatPath: boolean, + opts: { + isStaticRequest?: boolean; + isRouteRequest?: boolean; + requestContext?: unknown; + } = {} + ): Promise { + let [result] = await dataStrategy({ + matches: [match], + request, + type, + defaultStrategy(match) { + return callLoaderOrAction( + type, request, match, matches, manifest, mapRouteProperties, basename, - future.v7_relativeSplatPath - ) - ), - ...fetchersToLoad.map((f) => { - if (f.matches && f.match && f.controller) { - return callLoaderOrAction( - "loader", - createClientSideRequest(init.history, f.path, f.controller.signal), - f.match, - f.matches, - manifest, - mapRouteProperties, - basename, - future.v7_relativeSplatPath - ); - } else { - let error: ErrorResult = { - type: ResultType.error, - error: getInternalRouterError(404, { pathname: f.path }), - }; - return error; - } - }), + v7_relativeSplatPath, + opts + ); + }, + }); + + return result; + } + + async function loadDataAndMaybeResolveDeferred( + currentMatches: AgnosticDataRouteMatch[], + matches: AgnosticDataRouteMatch[], + matchesToLoad: AgnosticDataRouteMatch[], + fetchersToLoad: RevalidatingFetcher[], + request: Request + ) { + let fetchersToError: number[] = []; + let [loaderResults, fetcherResults] = await Promise.all([ + matchesToLoad.length + ? dataStrategy({ + matches: matchesToLoad, + request, + type: "loader", + defaultStrategy(match) { + return callLoaderOrAction( + "loader", + request, + match, + matches, + manifest, + mapRouteProperties, + basename, + future.v7_relativeSplatPath + ); + }, + }) + : [], + fetchersToLoad.length + ? dataStrategy({ + matches: fetchersToLoad + .filter((f, index) => { + if (f.matches && f.match && f.controller) { + return true; + } + + fetchersToError.push(index); + return false; + }) + .map((f) => f.match!), + request, + type: "loader", + defaultStrategy(match) { + let f = fetchersToLoad.find((f) => f.match === match); + invariant(f, "Expected fetcher for match in defaultStrategy"); + invariant( + f.controller, + "Expected controller for fetcher in defaultStrategy" + ); + invariant( + f.matches, + "Expected matches for fetcher in defaultStrategy" + ); + + return callLoaderOrAction( + "loader", + createClientSideRequest( + init.history, + f.path, + f.controller.signal + ), + match, + f.matches, + manifest, + mapRouteProperties, + basename, + future.v7_relativeSplatPath + ); + }, + }) + : [], ]); - let loaderResults = results.slice(0, matchesToLoad.length); - let fetcherResults = results.slice(matchesToLoad.length); + + // insert an error result for fetchers that didn't have either a + // match, matches, or controller + fetchersToError.forEach((idx) => { + fetcherResults.splice(idx, 0, { + type: ResultType.error, + error: getInternalRouterError(404, { + pathname: fetchersToLoad[idx].path, + }), + }); + }); await Promise.all([ resolveDeferredResults( @@ -2456,7 +2535,10 @@ export function createRouter(init: RouterInit): Router { ), ]); - return { results, loaderResults, fetcherResults }; + return { + loaderResults, + fetcherResults, + }; } function interruptActiveLoads() { @@ -3342,6 +3424,13 @@ export function createStaticHandler( //#region Helpers //////////////////////////////////////////////////////////////////////////////// +function defaultDataStrategy({ + defaultStrategy, + matches, +}: DataStrategyFunctionArgs) { + return Promise.all(matches.map((match) => defaultStrategy(match))); +} + /** * Given an existing StaticHandlerContext and an error thrown at render time, * provide an updated StaticHandlerContext suitable for a second SSR render diff --git a/packages/router/utils.ts b/packages/router/utils.ts index bc9a4b211b..b75f1d0ee1 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -223,6 +223,17 @@ export interface DetectErrorBoundaryFunction { (route: AgnosticRouteObject): boolean; } +export interface DataStrategyFunctionArgs { + request: Request; + matches: AgnosticDataRouteMatch[]; + type: "loader" | "action"; + defaultStrategy(match: AgnosticDataRouteMatch): Promise; +} + +export interface DataStrategyFunction { + (args: DataStrategyFunctionArgs): Promise; +} + /** * Function provided by the framework-aware layers to set any framework-specific * properties from framework-agnostic properties From 6bbc2431a9c67bc3b103487b6e9e58e0a4b75308 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 7 Dec 2023 10:46:27 -0800 Subject: [PATCH 02/50] =?UTF-8?q?2=20hours......=20=F0=9F=98=AE=E2=80=8D?= =?UTF-8?q?=F0=9F=92=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/router/__tests__/fetchers-test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/router/__tests__/fetchers-test.ts b/packages/router/__tests__/fetchers-test.ts index 4073a14df6..00481fe919 100644 --- a/packages/router/__tests__/fetchers-test.ts +++ b/packages/router/__tests__/fetchers-test.ts @@ -2719,6 +2719,7 @@ describe("fetchers", () => { let key = "KEY"; await t.fetch("/parent"); + await tick(); expect(t.router.state.errors).toMatchInlineSnapshot(` { "parent": ErrorResponseImpl { @@ -2754,6 +2755,7 @@ describe("fetchers", () => { let key = "KEY"; await t.fetch("/parent?index"); + await tick(); expect(t.router.state.errors).toMatchInlineSnapshot(` { "parent": ErrorResponseImpl { From 4c21cf92d4793fcd4841e416881926e948801f5b Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 7 Dec 2023 12:02:24 -0800 Subject: [PATCH 03/50] add static handler paths --- packages/router/router.ts | 104 +++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 47 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 0dfc6855e7..92f7a441c8 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -756,6 +756,7 @@ export function createRouter(init: RouterInit): Router { ); const dataStrategy = init.dataStrategy || defaultDataStrategy; + const callLoaderOrAction = createCallLoaderOrAction(dataStrategy); let mapRouteProperties: MapRoutePropertiesFunction; if (init.mapRouteProperties) { @@ -1572,7 +1573,7 @@ export function createRouter(init: RouterInit): Router { }), }; } else { - result = await loadOrMutateData( + result = await callLoaderOrAction( "action", request, actionMatch, @@ -1966,7 +1967,7 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let actionResult = await loadOrMutateData( + let actionResult = await callLoaderOrAction( "action", fetchRequest, match, @@ -2211,7 +2212,7 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let result: DataResult = await loadOrMutateData( + let result: DataResult = await callLoaderOrAction( "loader", fetchRequest, match, @@ -2396,43 +2397,6 @@ export function createRouter(init: RouterInit): Router { } } - async function loadOrMutateData( - type: "loader" | "action", - request: Request, - match: AgnosticDataRouteMatch, - matches: AgnosticDataRouteMatch[], - manifest: RouteManifest, - mapRouteProperties: MapRoutePropertiesFunction, - basename: string, - v7_relativeSplatPath: boolean, - opts: { - isStaticRequest?: boolean; - isRouteRequest?: boolean; - requestContext?: unknown; - } = {} - ): Promise { - let [result] = await dataStrategy({ - matches: [match], - request, - type, - defaultStrategy(match) { - return callLoaderOrAction( - type, - request, - match, - matches, - manifest, - mapRouteProperties, - basename, - v7_relativeSplatPath, - opts - ); - }, - }); - - return result; - } - async function loadDataAndMaybeResolveDeferred( currentMatches: AgnosticDataRouteMatch[], matches: AgnosticDataRouteMatch[], @@ -2905,6 +2869,7 @@ export interface CreateStaticHandlerOptions { * @deprecated Use `mapRouteProperties` instead */ detectErrorBoundary?: DetectErrorBoundaryFunction; + dataStrategy?: DataStrategyFunction; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; } @@ -2918,6 +2883,9 @@ export function createStaticHandler( "You must provide a non-empty routes array to createStaticHandler" ); + const dataStrategy = opts?.dataStrategy || defaultDataStrategy; + const callLoaderOrAction = createCallLoaderOrAction(dataStrategy); + let manifest: RouteManifest = {}; let basename = (opts ? opts.basename : null) || "/"; let mapRouteProperties: MapRoutePropertiesFunction; @@ -3358,9 +3326,12 @@ export function createStaticHandler( }; } - let results = await Promise.all([ - ...matchesToLoad.map((match) => - callLoaderOrAction( + let results = await dataStrategy({ + matches: matchesToLoad, + request, + type: "loader", + defaultStrategy(match) { + return callLoaderOrActionImplementation( "loader", request, match, @@ -3370,9 +3341,9 @@ export function createStaticHandler( basename, future.v7_relativeSplatPath, { isStaticRequest: true, isRouteRequest, requestContext } - ) - ), - ]); + ); + }, + }); if (request.signal.aborted) { let method = isRouteRequest ? "queryRoute" : "query"; @@ -4003,7 +3974,46 @@ async function loadLazyRouteModule( }); } -async function callLoaderOrAction( +function createCallLoaderOrAction(dataStrategy: DataStrategyFunction) { + return async ( + type: "loader" | "action", + request: Request, + match: AgnosticDataRouteMatch, + matches: AgnosticDataRouteMatch[], + manifest: RouteManifest, + mapRouteProperties: MapRoutePropertiesFunction, + basename: string, + v7_relativeSplatPath: boolean, + opts: { + isStaticRequest?: boolean; + isRouteRequest?: boolean; + requestContext?: unknown; + } = {} + ): Promise => { + let [result] = await dataStrategy({ + matches: [match], + request, + type, + defaultStrategy(match) { + return callLoaderOrActionImplementation( + type, + request, + match, + matches, + manifest, + mapRouteProperties, + basename, + v7_relativeSplatPath, + opts + ); + }, + }); + + return result; + }; +} + +async function callLoaderOrActionImplementation( type: "loader" | "action", request: Request, match: AgnosticDataRouteMatch, From 5ecb636254d5be67325d11dbfd67f4c9f24603c8 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 7 Dec 2023 12:40:13 -0800 Subject: [PATCH 04/50] added some initial tests --- packages/router/__tests__/router-test.ts | 208 +++++++++++++++++- .../__tests__/utils/data-router-setup.ts | 4 + 2 files changed, 210 insertions(+), 2 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index a5145d0978..015ea2db75 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -1,7 +1,11 @@ import type { HydrationState } from "../index"; import { createMemoryHistory, createRouter, IDLE_NAVIGATION } from "../index"; -import type { AgnosticDataRouteObject, AgnosticRouteObject } from "../utils"; -import { ErrorResponseImpl } from "../utils"; +import type { + AgnosticDataRouteObject, + AgnosticRouteObject, + DataResult, +} from "../utils"; +import { ErrorResponseImpl, ResultType } from "../utils"; import { deferredData, @@ -2473,6 +2477,206 @@ describe("a router", () => { }); }); + describe("router dataStrategy", () => { + it("should unwrap json and text by default", async () => { + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "json", + path: "/test", + loader: true, + children: [ + { + id: "text", + index: true, + loader: true, + }, + ], + }, + ], + }); + + let A = await t.navigate("/test"); + await A.loaders.json.resolve( + new Response(JSON.stringify({ message: "hello json" }), { + headers: { + "Content-Type": "application/json", + }, + }) + ); + await A.loaders.text.resolve(new Response("hello text")); + + expect(t.router.state.loaderData).toEqual({ + json: { message: "hello json" }, + text: "hello text", + }); + }); + + it("should allow a custom implementation to passthrough to default behavior", async () => { + let dataStrategy = jest.fn(({ matches, defaultStrategy }) => { + return Promise.all(matches.map((match) => defaultStrategy(match))); + }); + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "json", + path: "/test", + loader: true, + children: [ + { + id: "text", + index: true, + loader: true, + }, + ], + }, + ], + dataStrategy, + }); + + let A = await t.navigate("/test"); + await A.loaders.json.resolve( + new Response(JSON.stringify({ message: "hello json" }), { + headers: { + "Content-Type": "application/json", + }, + }) + ); + await A.loaders.text.resolve(new Response("hello text")); + + expect(t.router.state.loaderData).toEqual({ + json: { message: "hello json" }, + text: "hello text", + }); + expect(dataStrategy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "loader", + request: expect.any(Request), + matches: expect.arrayContaining([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "json", + }), + }), + expect.objectContaining({ + route: expect.objectContaining({ + id: "text", + }), + }), + ]), + }) + ); + }); + + it("should allow custom implementations to override default behavior", async () => { + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "test", + path: "/test", + loader: true, + }, + ], + dataStrategy({ matches, request }) { + return Promise.all( + matches.map((match) => + Promise.resolve( + match.route.loader!({ params: match.params, request }) + ).then(async (response): Promise => { + if (response instanceof Response) { + if ( + response.headers.get("Content-Type") === + "application/x-www-form-urlencoded" + ) { + let text = await response.text(); + const data = new URLSearchParams(text); + return { + type: ResultType.data, + data, + statusCode: response.status, + headers: response.headers, + }; + } + } + throw new Error("Unknown Content-Type"); + }) + ) + ); + }, + }); + + let A = await t.navigate("/test"); + await A.loaders.test.resolve( + new Response(new URLSearchParams({ a: "1", b: "2" }).toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + ); + + expect(t.router.state.loaderData.test).toBeInstanceOf(URLSearchParams); + expect(t.router.state.loaderData.test.toString()).toBe("a=1&b=2"); + }); + + it("handles errors at the proper boundary", async () => { + let t = setup({ + routes: [ + { + path: "/", + }, + { + path: "/parent", + children: [ + { + id: "child", + path: "child", + hasErrorBoundary: true, + children: [ + { + id: "test", + index: true, + loader: true, + }, + ], + }, + ], + }, + ], + dataStrategy({ matches, request }) { + return Promise.all( + matches.map((match) => + Promise.resolve( + match.route.loader!({ params: match.params, request }) + ).then(async (response): Promise => { + return { + type: ResultType.error, + error: new Error("Unable to unwrap response"), + }; + }) + ) + ); + }, + }); + + let A = await t.navigate("/parent/child"); + await A.loaders.test.resolve(new Response("hello world")); + + expect(t.router.state.loaderData.test).toBeUndefined(); + expect(t.router.state.errors?.child.message).toBe( + "Unable to unwrap response" + ); + }); + }); + describe("router.dispose", () => { it("should cancel pending navigations", async () => { let t = setup({ diff --git a/packages/router/__tests__/utils/data-router-setup.ts b/packages/router/__tests__/utils/data-router-setup.ts index 94bcee48fa..546fe9e46e 100644 --- a/packages/router/__tests__/utils/data-router-setup.ts +++ b/packages/router/__tests__/utils/data-router-setup.ts @@ -23,6 +23,7 @@ import { invariant } from "../../history"; import type { AgnosticIndexRouteObject, AgnosticNonIndexRouteObject, + DataStrategyFunction, } from "../../utils"; import { stripBasename } from "../../utils"; @@ -143,6 +144,7 @@ type SetupOpts = { initialIndex?: number; hydrationData?: HydrationState; future?: FutureConfig; + dataStrategy?: DataStrategyFunction; }; // We use a slightly modified version of createDeferred here that includes the @@ -177,6 +179,7 @@ export function setup({ initialIndex, hydrationData, future, + dataStrategy, }: SetupOpts) { let guid = 0; // Global "active" helpers, keyed by navType:guid:loaderOrAction:routeId. @@ -318,6 +321,7 @@ export function setup({ hydrationData, future, window: testWindow, + dataStrategy, }).initialize(); function getRouteHelpers( From 2ca15064e61ef7989382c063cded71a2b26c8e5c Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 7 Dec 2023 12:51:33 -0800 Subject: [PATCH 05/50] fetchers are always called on their own --- packages/router/router.ts | 86 +++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 92f7a441c8..ecc732da56 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -2405,7 +2405,7 @@ export function createRouter(init: RouterInit): Router { request: Request ) { let fetchersToError: number[] = []; - let [loaderResults, fetcherResults] = await Promise.all([ + let [loaderResults, ...fetcherResults] = await Promise.all([ matchesToLoad.length ? dataStrategy({ matches: matchesToLoad, @@ -2425,49 +2425,49 @@ export function createRouter(init: RouterInit): Router { }, }) : [], - fetchersToLoad.length - ? dataStrategy({ - matches: fetchersToLoad - .filter((f, index) => { - if (f.matches && f.match && f.controller) { - return true; - } - - fetchersToError.push(index); - return false; - }) - .map((f) => f.match!), - request, - type: "loader", - defaultStrategy(match) { - let f = fetchersToLoad.find((f) => f.match === match); - invariant(f, "Expected fetcher for match in defaultStrategy"); - invariant( - f.controller, - "Expected controller for fetcher in defaultStrategy" - ); - invariant( - f.matches, - "Expected matches for fetcher in defaultStrategy" - ); + ...fetchersToLoad.map((f) => { + if (!f.matches || !f.match || !f.controller) { + return Promise.resolve({ + type: ResultType.error, + error: getInternalRouterError(404, { + pathname: f.path, + }), + }); + } - return callLoaderOrAction( - "loader", - createClientSideRequest( - init.history, - f.path, - f.controller.signal - ), - match, - f.matches, - manifest, - mapRouteProperties, - basename, - future.v7_relativeSplatPath - ); - }, - }) - : [], + return dataStrategy({ + matches: [f.match!], + request, + type: "loader", + defaultStrategy(match) { + let f = fetchersToLoad.find((f) => f.match === match); + invariant(f, "Expected fetcher for match in defaultStrategy"); + invariant( + f.controller, + "Expected controller for fetcher in defaultStrategy" + ); + invariant( + f.matches, + "Expected matches for fetcher in defaultStrategy" + ); + + return callLoaderOrAction( + "loader", + createClientSideRequest( + init.history, + f.path, + f.controller.signal + ), + match, + f.matches, + manifest, + mapRouteProperties, + basename, + future.v7_relativeSplatPath + ); + }, + }).then((r) => r[0]); + }), ]); // insert an error result for fetchers that didn't have either a From 48d23f14a929b1709d5cac5f3d89231997f85037 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 7 Dec 2023 12:52:54 -0800 Subject: [PATCH 06/50] remove unused lines --- packages/router/router.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index ecc732da56..4757879ea7 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -2404,7 +2404,6 @@ export function createRouter(init: RouterInit): Router { fetchersToLoad: RevalidatingFetcher[], request: Request ) { - let fetchersToError: number[] = []; let [loaderResults, ...fetcherResults] = await Promise.all([ matchesToLoad.length ? dataStrategy({ @@ -2470,17 +2469,6 @@ export function createRouter(init: RouterInit): Router { }), ]); - // insert an error result for fetchers that didn't have either a - // match, matches, or controller - fetchersToError.forEach((idx) => { - fetcherResults.splice(idx, 0, { - type: ResultType.error, - error: getInternalRouterError(404, { - pathname: fetchersToLoad[idx].path, - }), - }); - }); - await Promise.all([ resolveDeferredResults( currentMatches, From 66c8b7d17b7ca79424aa4654e7e70f31627dfc2d Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 7 Dec 2023 21:43:30 -0800 Subject: [PATCH 07/50] add a few more tests --- .../__tests__/data-memory-router-test.tsx | 154 +++++++++++++++++- packages/react-router/index.ts | 3 + packages/router/__tests__/ssr-test.ts | 86 ++++++++++ packages/router/index.ts | 4 + 4 files changed, 246 insertions(+), 1 deletion(-) diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index 7946c8b95c..4919f6c597 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -1,4 +1,9 @@ -import type { ErrorResponse } from "@remix-run/router"; +import type { + DataResult, + DataStrategyFunctionArgs, + ErrorResponse, +} from "@remix-run/router"; +import { ResultType } from "@remix-run/router"; import "@testing-library/jest-dom"; import { fireEvent, @@ -33,6 +38,7 @@ import { import { createDeferred } from "../../router/__tests__/utils/utils"; import MemoryNavigate from "./utils/MemoryNavigate"; import getHtml from "./utils/getHtml"; +import {} from "@remix-run/router"; describe("createMemoryRouter", () => { let consoleWarn: jest.SpyInstance; @@ -3165,4 +3171,150 @@ describe("createMemoryRouter", () => { `); }); }); + + describe("router dataStrategy", () => { + it("executes route loaders on navigation", async () => { + let barDefer = createDeferred(); + let router = createMemoryRouter( + createRoutesFromElements( + }> + } /> + barDefer.promise} + element={} + /> + + ), + { initialEntries: ["/foo"], dataStrategy: urlDataStrategy } + ); + let { container } = render(); + + function Layout() { + let navigation = useNavigation(); + return ( +
+ Link to Bar +

{navigation.state}

+ +
+ ); + } + + function Foo() { + return

Foo

; + } + function Bar() { + let data = useLoaderData() as URLSearchParams; + return

{data?.get("message")}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+ + Link to Bar + +

+ idle +

+

+ Foo +

+
+
" + `); + + fireEvent.click(screen.getByText("Link to Bar")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+ + Link to Bar + +

+ loading +

+

+ Foo +

+
+
" + `); + + // barDefer.resolve({ message: "Bar Loader" }); + barDefer.resolve( + new Response( + new URLSearchParams([["message", "Bar Loader"]]).toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ) + ); + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+ + Link to Bar + +

+ idle +

+

+ Bar Loader +

+
+
" + `); + }); + }); }); + +async function urlDataStrategy({ + matches, + request, + type, +}: DataStrategyFunctionArgs): Promise { + return Promise.all( + matches.map>((match) => { + try { + let handler = + type === "loader" ? match.route.loader : match.route.action; + return Promise.resolve(handler!({ params: match.params, request })) + .then(async (response) => { + if ( + !(response instanceof Response) || + !response.headers + .get("Content-Type") + ?.match(/\bapplication\/x-www-form-urlencoded\b/) + ) { + throw new Error("Invalid response"); + } + return new URLSearchParams(await response.text()); + }) + .then((data) => ({ + type: ResultType.data, + data, + })) + .catch((error) => ({ + type: ResultType.error, + error, + })); + } catch (error) { + return Promise.resolve({ + type: ResultType.error, + error, + }); + } + }) + ); +} diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 980b799a59..60300fac3b 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -4,6 +4,7 @@ import type { ActionFunctionArgs, Blocker, BlockerFunction, + DataStrategyFunction, ErrorResponse, Fetcher, HydrationState, @@ -288,6 +289,7 @@ export function createMemoryRouter( hydrationData?: HydrationState; initialEntries?: InitialEntry[]; initialIndex?: number; + dataStrategy?: DataStrategyFunction; } ): RemixRouter { return createRouter({ @@ -303,6 +305,7 @@ export function createMemoryRouter( hydrationData: opts?.hydrationData, routes, mapRouteProperties, + dataStrategy: opts?.dataStrategy, }).initialize(); } diff --git a/packages/router/__tests__/ssr-test.ts b/packages/router/__tests__/ssr-test.ts index 64e58f4bc1..e6dc81e603 100644 --- a/packages/router/__tests__/ssr-test.ts +++ b/packages/router/__tests__/ssr-test.ts @@ -2,11 +2,13 @@ import type { StaticHandler, StaticHandlerContext } from "../router"; import { UNSAFE_DEFERRED_SYMBOL, createStaticHandler } from "../router"; import { ErrorResponseImpl, + ResultType, defer, isRouteErrorResponse, json, redirect, } from "../utils"; +import type { DataResult, DataStrategyFunctionArgs } from "../utils"; import { deferredData, trackedPromise } from "./utils/custom-matchers"; import { createDeferred } from "./utils/data-router-setup"; import { createRequest, createSubmitRequest } from "./utils/utils"; @@ -122,6 +124,14 @@ describe("ssr", () => { path: "/redirect", loader: () => redirect("/"), }, + { + id: "custom", + path: "/custom", + loader: () => + new Response(new URLSearchParams([["foo", "bar"]]).toString(), { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }), + }, ]; // Regardless of if the URL is internal or external - all absolute URL @@ -1281,6 +1291,28 @@ describe("ssr", () => { ]); }); }); + + describe("router dataStrategy", () => { + it("should support document load navigations with custom dataStrategy", async () => { + let { query } = createStaticHandler(SSR_ROUTES, { + dataStrategy: urlDataStrategy, + }); + + let context = await query(createRequest("/custom")); + expect(context).toMatchObject({ + actionData: null, + loaderData: { + custom: expect.any(URLSearchParams), + }, + errors: null, + location: { pathname: "/custom" }, + matches: [{ route: { id: "custom" } }], + }); + expect( + (context as StaticHandlerContext).loaderData.custom.get("foo") + ).toEqual("bar"); + }); + }); }); describe("singular route requests", () => { @@ -2191,5 +2223,59 @@ describe("ssr", () => { /* eslint-enable jest/no-conditional-expect */ }); + + describe("router dataStrategy", () => { + it("should match routes automatically if no routeId is provided", async () => { + let { queryRoute } = createStaticHandler(SSR_ROUTES, { + dataStrategy: urlDataStrategy, + }); + let data; + + data = await queryRoute(createRequest("/custom")); + console.log(data); + expect(data).toBeInstanceOf(URLSearchParams); + expect((data as URLSearchParams).get("foo")).toBe("bar"); + }); + }); }); }); + +async function urlDataStrategy({ + matches, + request, + type, +}: DataStrategyFunctionArgs): Promise { + return Promise.all( + matches.map>((match) => { + try { + let handler = + type === "loader" ? match.route.loader : match.route.action; + return Promise.resolve(handler!({ params: match.params, request })) + .then(async (response) => { + if ( + !(response instanceof Response) || + !response.headers + .get("Content-Type") + ?.match(/\bapplication\/x-www-form-urlencoded\b/) + ) { + throw new Error("Invalid response"); + } + return new URLSearchParams(await response.text()); + }) + .then((data) => ({ + type: ResultType.data, + data, + })) + .catch((error) => ({ + type: ResultType.error, + error, + })); + } catch (error) { + return Promise.resolve({ + type: ResultType.error, + error, + }); + } + }) + ); +} diff --git a/packages/router/index.ts b/packages/router/index.ts index 060360d34a..bc472361e7 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -9,6 +9,9 @@ export type { AgnosticNonIndexRouteObject, AgnosticRouteMatch, AgnosticRouteObject, + DataResult, + DataStrategyFunction, + DataStrategyFunctionArgs, ErrorResponse, FormEncType, FormMethod, @@ -45,6 +48,7 @@ export { redirectDocument, resolvePath, resolveTo, + ResultType, stripBasename, } from "./utils"; From 562cd609625c30374666a493ec6baa3e0eda5fcd Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Fri, 8 Dec 2023 15:51:54 -0800 Subject: [PATCH 08/50] feat: initial support for lazy --- packages/router/__tests__/lazy-test.ts | 2 + packages/router/__tests__/router-test.ts | 556 +++++++++++++++-------- packages/router/index.ts | 1 + packages/router/router.ts | 89 +++- packages/router/utils.ts | 12 +- 5 files changed, 452 insertions(+), 208 deletions(-) diff --git a/packages/router/__tests__/lazy-test.ts b/packages/router/__tests__/lazy-test.ts index aa7f7ad53f..787a8da523 100644 --- a/packages/router/__tests__/lazy-test.ts +++ b/packages/router/__tests__/lazy-test.ts @@ -212,6 +212,8 @@ describe("lazily loaded route modules", () => { expect(A.lazy.lazy.stub).toHaveBeenCalled(); let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); + // Hmm, this feels like something's missing... + t.router.routes[0].lazy = undefined; await A.lazy.lazy.resolve({ loader: lazyLoaderStub, }); diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 015ea2db75..6008605019 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -2478,203 +2478,387 @@ describe("a router", () => { }); describe("router dataStrategy", () => { - it("should unwrap json and text by default", async () => { - let t = setup({ - routes: [ - { - path: "/", - }, - { - id: "json", - path: "/test", - loader: true, - children: [ - { - id: "text", - index: true, - loader: true, - }, - ], - }, - ], - }); - - let A = await t.navigate("/test"); - await A.loaders.json.resolve( - new Response(JSON.stringify({ message: "hello json" }), { - headers: { - "Content-Type": "application/json", - }, - }) - ); - await A.loaders.text.resolve(new Response("hello text")); - - expect(t.router.state.loaderData).toEqual({ - json: { message: "hello json" }, - text: "hello text", - }); - }); + describe("loader", () => { + it("should unwrap json and text by default", async () => { + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "json", + path: "/test", + loader: true, + children: [ + { + id: "text", + index: true, + loader: true, + }, + ], + }, + ], + }); - it("should allow a custom implementation to passthrough to default behavior", async () => { - let dataStrategy = jest.fn(({ matches, defaultStrategy }) => { - return Promise.all(matches.map((match) => defaultStrategy(match))); - }); - let t = setup({ - routes: [ - { - path: "/", - }, - { - id: "json", - path: "/test", - loader: true, - children: [ - { - id: "text", - index: true, - loader: true, - }, - ], - }, - ], - dataStrategy, + let A = await t.navigate("/test"); + await A.loaders.json.resolve( + new Response(JSON.stringify({ message: "hello json" }), { + headers: { + "Content-Type": "application/json", + }, + }) + ); + await A.loaders.text.resolve(new Response("hello text")); + + expect(t.router.state.loaderData).toEqual({ + json: { message: "hello json" }, + text: "hello text", + }); + }); + + it("should allow a custom implementation to passthrough to default behavior", async () => { + let dataStrategy = jest.fn(({ matches, defaultStrategy }) => { + return Promise.all(matches.map((match) => defaultStrategy(match))); + }); + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "json", + path: "/test", + loader: true, + children: [ + { + id: "text", + index: true, + loader: true, + }, + ], + }, + ], + dataStrategy, + }); + + let A = await t.navigate("/test"); + await A.loaders.json.resolve( + new Response(JSON.stringify({ message: "hello json" }), { + headers: { + "Content-Type": "application/json", + }, + }) + ); + await A.loaders.text.resolve(new Response("hello text")); + + expect(t.router.state.loaderData).toEqual({ + json: { message: "hello json" }, + text: "hello text", + }); + expect(dataStrategy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "loader", + request: expect.any(Request), + matches: expect.arrayContaining([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "json", + }), + }), + expect.objectContaining({ + route: expect.objectContaining({ + id: "text", + }), + }), + ]), + }) + ); }); - let A = await t.navigate("/test"); - await A.loaders.json.resolve( - new Response(JSON.stringify({ message: "hello json" }), { - headers: { - "Content-Type": "application/json", - }, - }) - ); - await A.loaders.text.resolve(new Response("hello text")); - - expect(t.router.state.loaderData).toEqual({ - json: { message: "hello json" }, - text: "hello text", - }); - expect(dataStrategy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "loader", - request: expect.any(Request), - matches: expect.arrayContaining([ - expect.objectContaining({ - route: expect.objectContaining({ - id: "json", - }), - }), - expect.objectContaining({ - route: expect.objectContaining({ - id: "text", + it("should allow a custom implementation to passthrough to default behavior and lazy", async () => { + let dataStrategy = jest.fn(({ matches, defaultStrategy }) => { + return Promise.all(matches.map((match) => defaultStrategy(match))); + }); + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "json", + path: "/test", + lazy: true, + }, + ], + dataStrategy, + }); + + let A = await t.navigate("/test"); + await A.lazy.json.resolve({ + loader: () => ({ message: "hello json" }), + }); + expect(t.router.state.loaderData).toEqual({ + json: { message: "hello json" }, + }); + expect(dataStrategy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "loader", + request: expect.any(Request), + matches: expect.arrayContaining([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "json", + }), }), - }), - ]), - }) - ); - }); + ]), + }) + ); + }); - it("should allow custom implementations to override default behavior", async () => { - let t = setup({ - routes: [ - { - path: "/", - }, - { - id: "test", - path: "/test", - loader: true, - }, - ], - dataStrategy({ matches, request }) { - return Promise.all( - matches.map((match) => - Promise.resolve( - match.route.loader!({ params: match.params, request }) - ).then(async (response): Promise => { - if (response instanceof Response) { - if ( - response.headers.get("Content-Type") === - "application/x-www-form-urlencoded" - ) { - let text = await response.text(); - const data = new URLSearchParams(text); - return { - type: ResultType.data, - data, - statusCode: response.status, - headers: response.headers, - }; + it("should allow custom implementations to override default behavior", async () => { + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "test", + path: "/test", + loader: true, + }, + ], + dataStrategy({ matches, request }) { + return Promise.all( + matches.map(async (match) => + Promise.resolve( + (await match.route).loader!({ params: match.params, request }) + ).then(async (response): Promise => { + if (response instanceof Response) { + if ( + response.headers.get("Content-Type") === + "application/x-www-form-urlencoded" + ) { + let text = await response.text(); + const data = new URLSearchParams(text); + return { + type: ResultType.data, + data, + statusCode: response.status, + headers: response.headers, + }; + } } - } - throw new Error("Unknown Content-Type"); - }) - ) - ); - }, + throw new Error("Unknown Content-Type"); + }) + ) + ); + }, + }); + + let A = await t.navigate("/test"); + await A.loaders.test.resolve( + new Response(new URLSearchParams({ a: "1", b: "2" }).toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + ); + + expect(t.router.state.loaderData.test).toBeInstanceOf(URLSearchParams); + expect(t.router.state.loaderData.test.toString()).toBe("a=1&b=2"); }); - let A = await t.navigate("/test"); - await A.loaders.test.resolve( - new Response(new URLSearchParams({ a: "1", b: "2" }).toString(), { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }) - ); + it("should allow custom implementations to override default behavior with lazy", async () => { + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "test", + path: "/test", + lazy: true, + }, + ], + async dataStrategy({ type, matches, request }) { + // const handles = await getMatchRouteModulesAndGetHandles(matches); + + // return fetchStuffFromSomewhere(handles).then((response) => + // decodeIntoIndivualMatches(response).map((data) => ({ + // type: ResultType.data, + // data, + // })) + // ); + + // return Promise.all( + // matches.map(async (match) => { + // const routeModule = await match.route; + // const handler = routeModule[type]; + // return Promise.resolve( + // handler({ params: match.params, request }) + // ) + // .then((response) => ({ + // type: ResultType.data, + // data: decodeResponse(response), + // })) + // .catch((error) => ({ + // type: ResultType.error, + // error, + // })); + // }) + // ); + + return Promise.all( + matches.map(async (match) => + Promise.resolve( + (await match.route).loader!({ params: match.params, request }) + ).then(async (response): Promise => { + if (response instanceof Response) { + if ( + response.headers.get("Content-Type") === + "application/x-www-form-urlencoded" + ) { + let text = await response.text(); + const data = new URLSearchParams(text); + return { + type: ResultType.data, + data, + statusCode: response.status, + headers: response.headers, + }; + } + } + throw new Error("Unknown Content-Type"); + }) + ) + ); + }, + }); + + let A = await t.navigate("/test"); + await A.lazy.test.resolve({ + loader: () => + new Response(new URLSearchParams({ a: "1", b: "2" }).toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }), + }); - expect(t.router.state.loaderData.test).toBeInstanceOf(URLSearchParams); - expect(t.router.state.loaderData.test.toString()).toBe("a=1&b=2"); - }); + expect(t.router.state.errors).toMatchInlineSnapshot(`null`); + expect(t.router.state.loaderData.test).toBeInstanceOf(URLSearchParams); + expect(t.router.state.loaderData.test.toString()).toBe("a=1&b=2"); + }); - it("handles errors at the proper boundary", async () => { - let t = setup({ - routes: [ - { - path: "/", - }, - { - path: "/parent", - children: [ - { - id: "child", - path: "child", - hasErrorBoundary: true, - children: [ - { - id: "test", - index: true, - loader: true, - }, - ], - }, - ], - }, - ], - dataStrategy({ matches, request }) { - return Promise.all( - matches.map((match) => - Promise.resolve( - match.route.loader!({ params: match.params, request }) - ).then(async (response): Promise => { - return { - type: ResultType.error, - error: new Error("Unable to unwrap response"), - }; - }) - ) - ); - }, - }); - - let A = await t.navigate("/parent/child"); - await A.loaders.test.resolve(new Response("hello world")); - - expect(t.router.state.loaderData.test).toBeUndefined(); - expect(t.router.state.errors?.child.message).toBe( - "Unable to unwrap response" - ); + it("handles errors at the proper boundary", async () => { + let t = setup({ + routes: [ + { + path: "/", + }, + { + path: "/parent", + children: [ + { + id: "child", + path: "child", + hasErrorBoundary: true, + children: [ + { + id: "test", + index: true, + loader: true, + }, + ], + }, + ], + }, + ], + dataStrategy({ matches, request }) { + return Promise.all( + matches.map((match) => + Promise.resolve( + match.route.loader!({ params: match.params, request }) + ).then(async (response): Promise => { + return { + type: ResultType.error, + error: new Error("Unable to unwrap response"), + }; + }) + ) + ); + }, + }); + + let A = await t.navigate("/parent/child"); + await A.loaders.test.resolve(new Response("hello world")); + + expect(t.router.state.loaderData.test).toBeUndefined(); + expect(t.router.state.errors?.child.message).toBe( + "Unable to unwrap response" + ); + }); }); + // describe("action", () => { + // it("should allow a custom implementation to passthrough to default behavior", async () => { + // let dataStrategy = jest.fn(({ matches, defaultStrategy }) => { + // return Promise.all(matches.map((match) => defaultStrategy(match))); + // }); + // let t = setup({ + // routes: [ + // { + // path: "/", + // }, + // { + // id: "json", + // path: "/test", + // loader: true, + // children: [ + // { + // id: "text", + // index: true, + // loader: true, + // }, + // ], + // }, + // ], + // dataStrategy, + // }); + + // let A = await t.navigate("/test"); + // await A.loaders.json.resolve( + // new Response(JSON.stringify({ message: "hello json" }), { + // headers: { + // "Content-Type": "application/json", + // }, + // }) + // ); + // await A.loaders.text.resolve(new Response("hello text")); + + // expect(t.router.state.loaderData).toEqual({ + // json: { message: "hello json" }, + // text: "hello text", + // }); + // expect(dataStrategy).toHaveBeenCalledWith( + // expect.objectContaining({ + // type: "loader", + // request: expect.any(Request), + // matches: expect.arrayContaining([ + // expect.objectContaining({ + // route: expect.objectContaining({ + // id: "json", + // }), + // }), + // expect.objectContaining({ + // route: expect.objectContaining({ + // id: "text", + // }), + // }), + // ]), + // }) + // ); + // }); + // }); }); describe("router.dispose", () => { diff --git a/packages/router/index.ts b/packages/router/index.ts index bc472361e7..61f4c5385b 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -5,6 +5,7 @@ export type { AgnosticDataNonIndexRouteObject, AgnosticDataRouteMatch, AgnosticDataRouteObject, + AgnosticDataStrategyMatch, AgnosticIndexRouteObject, AgnosticNonIndexRouteObject, AgnosticRouteMatch, diff --git a/packages/router/router.ts b/packages/router/router.ts index 4757879ea7..72bc25bc1c 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -11,6 +11,7 @@ import type { ActionFunction, AgnosticDataRouteMatch, AgnosticDataRouteObject, + AgnosticDataStrategyMatch, AgnosticRouteObject, DataResult, DataStrategyFunction, @@ -23,6 +24,7 @@ import type { FormMethod, HTMLFormMethod, ImmutableRouteKey, + LazyRoutePromise, LoaderFunction, MapRoutePropertiesFunction, MutationFormMethod, @@ -2407,7 +2409,13 @@ export function createRouter(init: RouterInit): Router { let [loaderResults, ...fetcherResults] = await Promise.all([ matchesToLoad.length ? dataStrategy({ - matches: matchesToLoad, + matches: matchesToLoad.map((m) => + finesseToAgnosticDataStrategyMatch( + m, + mapRouteProperties, + manifest + ) + ), request, type: "loader", defaultStrategy(match) { @@ -2435,12 +2443,16 @@ export function createRouter(init: RouterInit): Router { } return dataStrategy({ - matches: [f.match!], + matches: [ + finesseToAgnosticDataStrategyMatch( + f.match!, + mapRouteProperties, + manifest + ), + ], request, type: "loader", defaultStrategy(match) { - let f = fetchersToLoad.find((f) => f.match === match); - invariant(f, "Expected fetcher for match in defaultStrategy"); invariant( f.controller, "Expected controller for fetcher in defaultStrategy" @@ -3315,7 +3327,9 @@ export function createStaticHandler( } let results = await dataStrategy({ - matches: matchesToLoad, + matches: matchesToLoad.map((m) => + finesseToAgnosticDataStrategyMatch(m, mapRouteProperties, manifest) + ), request, type: "loader", defaultStrategy(match) { @@ -3324,8 +3338,6 @@ export function createStaticHandler( request, match, matches, - manifest, - mapRouteProperties, basename, future.v7_relativeSplatPath, { isStaticRequest: true, isRouteRequest, requestContext } @@ -3893,9 +3905,9 @@ async function loadLazyRouteModule( route: AgnosticDataRouteObject, mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest -) { +): Promise { if (!route.lazy) { - return; + return route; } let lazyRoute = await route.lazy(); @@ -3904,7 +3916,7 @@ async function loadLazyRouteModule( // call then we can return - first lazy() to finish wins because the return // value of lazy is expected to be static if (!route.lazy) { - return; + return route; } let routeToUpdate = manifest[route.id]; @@ -3960,6 +3972,8 @@ async function loadLazyRouteModule( ...mapRouteProperties(routeToUpdate), lazy: undefined, }); + + return route; } function createCallLoaderOrAction(dataStrategy: DataStrategyFunction) { @@ -3979,7 +3993,9 @@ function createCallLoaderOrAction(dataStrategy: DataStrategyFunction) { } = {} ): Promise => { let [result] = await dataStrategy({ - matches: [match], + matches: [ + finesseToAgnosticDataStrategyMatch(match, mapRouteProperties, manifest), + ], request, type, defaultStrategy(match) { @@ -3988,8 +4004,6 @@ function createCallLoaderOrAction(dataStrategy: DataStrategyFunction) { request, match, matches, - manifest, - mapRouteProperties, basename, v7_relativeSplatPath, opts @@ -4001,13 +4015,45 @@ function createCallLoaderOrAction(dataStrategy: DataStrategyFunction) { }; } +function finesseToAgnosticDataStrategyMatch( + match: AgnosticDataRouteMatch, + mapRouteProperties: MapRoutePropertiesFunction, + manifest: RouteManifest +): AgnosticDataStrategyMatch { + let loadRoutePromise: Promise | undefined; + + if (match.route.lazy) { + try { + loadRoutePromise = loadLazyRouteModule( + match.route, + mapRouteProperties, + manifest + ); + } catch (error) { + loadRoutePromise = Promise.reject(error); + } + } + + if (!loadRoutePromise) { + loadRoutePromise = Promise.resolve(match.route); + } + + loadRoutePromise.catch(() => {}); + + return { + ...match, + route: Object.assign( + loadRoutePromise, + match.route + ) as unknown as LazyRoutePromise, + }; +} + async function callLoaderOrActionImplementation( type: "loader" | "action", request: Request, - match: AgnosticDataRouteMatch, + match: AgnosticDataStrategyMatch, matches: AgnosticDataRouteMatch[], - manifest: RouteManifest, - mapRouteProperties: MapRoutePropertiesFunction, basename: string, v7_relativeSplatPath: boolean, opts: { @@ -4050,7 +4096,7 @@ async function callLoaderOrActionImplementation( runHandler(handler).catch((e) => { handlerError = e; }), - loadLazyRouteModule(match.route, mapRouteProperties, manifest), + match.route, ]); if (handlerError) { throw handlerError; @@ -4058,9 +4104,9 @@ async function callLoaderOrActionImplementation( result = values[0]; } else { // Load lazy route module, then run any returned handler - await loadLazyRouteModule(match.route, mapRouteProperties, manifest); + let route = await match.route; - handler = match.route[type]; + handler = route[type]; if (handler) { // Handler still run even if we got interrupted to maintain consistency // with un-abortable behavior of handler execution on non-lazy or @@ -4120,7 +4166,10 @@ async function callLoaderOrActionImplementation( if (!ABSOLUTE_URL_REGEX.test(location)) { location = normalizeTo( new URL(request.url), - matches.slice(0, matches.indexOf(match) + 1), + matches.slice( + 0, + matches.findIndex((m) => m.route.id === match.route.id) + 1 + ), basename, true, location, diff --git a/packages/router/utils.ts b/packages/router/utils.ts index b75f1d0ee1..bdfb7ce562 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -225,9 +225,9 @@ export interface DetectErrorBoundaryFunction { export interface DataStrategyFunctionArgs { request: Request; - matches: AgnosticDataRouteMatch[]; + matches: AgnosticDataStrategyMatch[]; type: "loader" | "action"; - defaultStrategy(match: AgnosticDataRouteMatch): Promise; + defaultStrategy(match: AgnosticDataStrategyMatch): Promise; } export interface DataStrategyFunction { @@ -412,6 +412,14 @@ export interface AgnosticRouteMatch< export interface AgnosticDataRouteMatch extends AgnosticRouteMatch {} +export type LazyRoutePromise = PromiseLike & + AgnosticDataRouteObject; + +export interface AgnosticDataStrategyMatch + extends Omit, "route"> { + route: LazyRoutePromise; +} + function isIndexRoute( route: AgnosticRouteObject ): route is AgnosticIndexRouteObject { From d6707a7fd8b06388d1ac874d8f7cc214664d67cc Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Sat, 9 Dec 2023 22:35:07 -0800 Subject: [PATCH 09/50] chore: add ssr single fetch example --- .gitignore | 1 + .../ssr-single-fetch/app/entry.client.tsx | 98 + .../ssr-single-fetch/app/entry.server.tsx | 115 + examples/ssr-single-fetch/app/glob-routes.ts | 535 ++++ examples/ssr-single-fetch/app/global.css | 12 + .../app/render-to-readable-stream.node.ts | 83 + .../app/routes/_layout._index/route.tsx | 18 + .../app/routes/_layout.about/route.tsx | 7 + .../app/routes/_layout/route.tsx | 51 + examples/ssr-single-fetch/index.html | 13 + examples/ssr-single-fetch/package-lock.json | 2464 +++++++++++++++++ examples/ssr-single-fetch/package.json | 33 + examples/ssr-single-fetch/remix.plugin.ts | 26 + examples/ssr-single-fetch/server.js | 61 + examples/ssr-single-fetch/tsconfig.json | 16 + examples/ssr-single-fetch/vite.config.js | 45 + packages/react-router-dom/index.tsx | 15 +- 17 files changed, 3592 insertions(+), 1 deletion(-) create mode 100644 examples/ssr-single-fetch/app/entry.client.tsx create mode 100644 examples/ssr-single-fetch/app/entry.server.tsx create mode 100644 examples/ssr-single-fetch/app/glob-routes.ts create mode 100644 examples/ssr-single-fetch/app/global.css create mode 100644 examples/ssr-single-fetch/app/render-to-readable-stream.node.ts create mode 100644 examples/ssr-single-fetch/app/routes/_layout._index/route.tsx create mode 100644 examples/ssr-single-fetch/app/routes/_layout.about/route.tsx create mode 100644 examples/ssr-single-fetch/app/routes/_layout/route.tsx create mode 100644 examples/ssr-single-fetch/index.html create mode 100644 examples/ssr-single-fetch/package-lock.json create mode 100644 examples/ssr-single-fetch/package.json create mode 100644 examples/ssr-single-fetch/remix.plugin.ts create mode 100644 examples/ssr-single-fetch/server.js create mode 100644 examples/ssr-single-fetch/tsconfig.json create mode 100644 examples/ssr-single-fetch/vite.config.js diff --git a/.gitignore b/.gitignore index 1387c7fdff..7b661bd88a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store npm-debug.log /docs/api/ diff --git a/examples/ssr-single-fetch/app/entry.client.tsx b/examples/ssr-single-fetch/app/entry.client.tsx new file mode 100644 index 0000000000..7581d3ddf0 --- /dev/null +++ b/examples/ssr-single-fetch/app/entry.client.tsx @@ -0,0 +1,98 @@ +// @ts-expect-error +import RefreshRuntime from "/@react-refresh"; +import * as React from "react"; +import * as ReactDOM from "react-dom/client"; +import { + createBrowserRouter, + matchRoutes, + ResultType, + RouterProvider, + type DataRouteObject, +} from "react-router-dom"; +import { decode } from "turbo-stream"; + +import { globRoutes } from "./glob-routes.js"; + +const routes = globRoutes(import.meta.glob("./routes/**/route.tsx")); + +async function initializeRoutes(routes: DataRouteObject[]) { + // Determine if any of the initial routes are lazy + let lazyMatches = matchRoutes(routes, window.location)?.filter( + (m) => m.route.lazy + ); + + // Load the lazy matches and update the routes before creating your router + // so we can hydrate the SSR-rendered content synchronously + if (lazyMatches && lazyMatches?.length > 0) { + await Promise.all( + lazyMatches.map(async (m) => { + let routeModule = await m.route.lazy!(); + Object.assign(m.route, { ...routeModule, lazy: undefined }); + }) + ); + } +} + +if (import.meta.env.DEV) { + RefreshRuntime.injectIntoGlobalHook(window); + // @ts-expect-error + window.$RefreshReg$ = () => {}; + // @ts-expect-error + window.$RefreshSig$ = () => (type) => type; + // @ts-expect-error + window.__vite_plugin_react_preamble_installed__ = true; +} + +initializeRoutes(routes) + .then(() => { + const router = createBrowserRouter(routes, { + async dataStrategy({ matches, request, type }) { + const singleFetchURL = new URL(request.url); + singleFetchURL.pathname = singleFetchURL.pathname + ".data"; + + const singleFetchHeaders = new Headers(request.headers); + singleFetchHeaders.set( + "X-Routes", + matches.map((m) => m.route.id).join(",") + ); + + const singleFetchRequest = new Request(singleFetchURL, { + body: request.body, + headers: singleFetchHeaders, + method: request.method, + signal: request.signal, + }); + + try { + const singleFetchResponse = await fetch(singleFetchRequest); + const decoded = await decode(singleFetchResponse.body!); + const data = decoded.value as { + actionData?: Record; + loaderData?: Record; + }; + + return matches.map((m) => ({ + type: ResultType.data, + data: data[`${type}Data`]?.[m.route.id], + })); + } catch (error) { + return [ + { + type: ResultType.error, + error: error, + }, + ]; + } + }, + }); + + React.startTransition(() => { + ReactDOM.hydrateRoot( + document, + + + + ); + }); + }) + .catch(console.error); diff --git a/examples/ssr-single-fetch/app/entry.server.tsx b/examples/ssr-single-fetch/app/entry.server.tsx new file mode 100644 index 0000000000..5838bfa4ae --- /dev/null +++ b/examples/ssr-single-fetch/app/entry.server.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { ResultType, UNSAFE_ErrorResponseImpl } from "react-router-dom"; +import { + createStaticHandler, + createStaticRouter, + StaticRouterProvider, +} from "react-router-dom/server.js"; +import { encode } from "turbo-stream"; + +import { globRoutes } from "./glob-routes.js"; +import { renderToReadableStream } from "./render-to-readable-stream.node.js"; + +const routes = globRoutes(import.meta.glob("./routes/**/route.tsx")); + +export async function render( + request: Request, + { + bootstrapModules, + bootstrapScriptContent, + }: { bootstrapModules?: string[]; bootstrapScriptContent?: string } +) { + let url = new URL(request.url); + let isDataRequest = url.pathname.endsWith(".data"); + let xRouteIds = request.headers.get("X-Routes")?.split(","); + + if (isDataRequest) { + request = new Request( + new URL(url.pathname.replace(/\.data$/, "") + url.search, url), + { + body: request.body, + headers: request.headers, + method: request.method, + signal: request.signal, + } + ); + } + + let { query, dataRoutes } = createStaticHandler(routes, { + async dataStrategy({ defaultStrategy, matches }) { + if (isDataRequest && xRouteIds?.length) { + let routesToLoad = new Set(xRouteIds); + + return Promise.all( + matches.map((match) => { + if (!routesToLoad!.has(match.route.id)) { + return { + type: ResultType.data, + data: undefined, + }; + } + + return defaultStrategy(match); + }) + ); + } + + return Promise.all( + matches.map((match) => { + return defaultStrategy(match); + }) + ); + }, + }); + + let context = await query(request); + + if (context instanceof Response) { + return context; + } + + if (isDataRequest) { + return new Response( + encode({ + actionData: context.actionData, + loaderData: context.loaderData, + }), + { + status: context.statusCode, + headers: { + "Content-Type": "text/turbo-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + Vary: "X-Routes", + }, + } + ); + } + + let router = createStaticRouter(dataRoutes, context); + + let body = await renderToReadableStream( + + + , + { + onError: console.error, + bootstrapModules, + bootstrapScriptContent, + signal: request.signal, + } + ); + + // TODO: handle headers + + return new Response(body, { + status: context.statusCode, + headers: { + "Content-Type": "text/html", + "Transfer-Encoding": "chunked", + }, + }); +} diff --git a/examples/ssr-single-fetch/app/glob-routes.ts b/examples/ssr-single-fetch/app/glob-routes.ts new file mode 100644 index 0000000000..bc4249f35b --- /dev/null +++ b/examples/ssr-single-fetch/app/glob-routes.ts @@ -0,0 +1,535 @@ +import type { DataRouteObject } from "react-router-dom"; + +export let paramPrefixChar = "$" as const; +export let escapeStart = "[" as const; +export let escapeEnd = "]" as const; + +export let optionalStart = "(" as const; +export let optionalEnd = ")" as const; + +type RouteModules = Record; +type RouteManifest = Record; +type RouteInfo = { + file: string; + id: string; + parentId?: string; + path?: string; + index?: boolean; +}; + +export function globRoutes( + globRoutes: Record Promise>, + prefix: string = "routes" +) { + const manifest = flatRoutesUniversal( + ".", + Object.entries(globRoutes) + .map(([path]) => path) + .sort((pathA, pathB) => pathA.length - pathB.length), + prefix + ); + + return createClientRoutes( + manifest, + Object.fromEntries( + Object.entries(globRoutes).map(([id, mod]) => [ + id.slice(1).replace(/\/route\.tsx$/, ""), + mod, + ]) + ) + ); +} + +const groupRoutesByParentId = (manifest: RouteManifest) => { + let routes: Record = {}; + + Object.values(manifest).forEach((route) => { + let parentId = route.parentId || ""; + if (!routes[parentId]) { + routes[parentId] = []; + } + routes[parentId].push(route); + }); + + return routes; +}; + +function createClientRoutes( + manifest: RouteManifest, + routeModulesCache: RouteModules, + parentId: string = "", + routesByParentId: Record = groupRoutesByParentId( + manifest + ), + needsRevalidation?: Set +): DataRouteObject[] { + return (routesByParentId[parentId] || []).map((route) => { + let routeModule = routeModulesCache?.[route.id] as any; + let dataRoute: DataRouteObject = { + id: route.id, + index: route.index, + path: route.path, + lazy: routeModule, + }; + + let children = createClientRoutes( + manifest, + routeModulesCache, + route.id, + routesByParentId, + needsRevalidation + ); + if (children.length > 0) dataRoute.children = children; + return dataRoute; + }); +} + +function flatRoutesUniversal( + appDirectory: string, + routes: string[], + prefix: string = "routes" +): RouteManifest { + let urlConflicts = new Map(); + let routeManifest: RouteManifest = {}; + let prefixLookup = new PrefixLookupTrie(); + let uniqueRoutes = new Map(); + let routeIdConflicts = new Map(); + + // id -> file + let routeIds = new Map(); + + for (let file of routes) { + let normalizedFile = normalizeSlashes(file); + let routeExt = normalizedFile.split(".").pop() || ""; + let routeDir = normalizedFile.split("/").slice(0, -1).join("/"); + let normalizedApp = normalizeSlashes(appDirectory); + let routeId = + routeDir === pathJoin(normalizedApp, prefix) + ? pathRelative(normalizedApp, normalizedFile).slice(0, -routeExt.length) + : pathRelative(normalizedApp, routeDir); + + let conflict = routeIds.get(routeId); + if (conflict) { + let currentConflicts = routeIdConflicts.get(routeId); + if (!currentConflicts) { + currentConflicts = [pathRelative(normalizedApp, conflict)]; + } + currentConflicts.push(pathRelative(normalizedApp, normalizedFile)); + routeIdConflicts.set(routeId, currentConflicts); + continue; + } + + routeIds.set(routeId, normalizedFile); + } + + let sortedRouteIds = Array.from(routeIds).sort( + ([a], [b]) => b.length - a.length + ); + + for (let [routeId, file] of sortedRouteIds) { + let index = routeId.endsWith("_index"); + let [segments, raw] = getRouteSegments(routeId.slice(prefix.length + 1)); + let pathname = createRoutePath(segments, raw, index); + + routeManifest[routeId] = { + file: file.slice(appDirectory.length + 1), + id: routeId, + path: pathname, + }; + if (index) routeManifest[routeId].index = true; + let childRouteIds = prefixLookup.findAndRemove(routeId, (value) => { + return [".", "/"].includes(value.slice(routeId.length).charAt(0)); + }); + prefixLookup.add(routeId); + + if (childRouteIds.length > 0) { + for (let childRouteId of childRouteIds) { + routeManifest[childRouteId].parentId = routeId; + } + } + } + + // path creation + let parentChildrenMap = new Map(); + for (let [routeId] of sortedRouteIds) { + let config = routeManifest[routeId]; + if (!config.parentId) continue; + let existingChildren = parentChildrenMap.get(config.parentId) || []; + existingChildren.push(config); + parentChildrenMap.set(config.parentId, existingChildren); + } + + for (let [routeId] of sortedRouteIds) { + let config = routeManifest[routeId]; + let originalPathname = config.path || ""; + let pathname = config.path; + let parentConfig = config.parentId ? routeManifest[config.parentId] : null; + if (parentConfig?.path && pathname) { + pathname = pathname + .slice(parentConfig.path.length) + .replace(/^\//, "") + .replace(/\/$/, ""); + } + + if (!config.parentId) config.parentId = ""; + config.path = pathname || undefined; + + /** + * We do not try to detect path collisions for pathless layout route + * files because, by definition, they create the potential for route + * collisions _at that level in the tree_. + * + * Consider example where a user may want multiple pathless layout routes + * for different subfolders + * + * routes/ + * account.tsx + * account._private.tsx + * account._private.orders.tsx + * account._private.profile.tsx + * account._public.tsx + * account._public.login.tsx + * account._public.perks.tsx + * + * In order to support both a public and private layout for `/account/*` + * URLs, we are creating a mutually exclusive set of URLs beneath 2 + * separate pathless layout routes. In this case, the route paths for + * both account._public.tsx and account._private.tsx is the same + * (/account), but we're again not expecting to match at that level. + * + * By only ignoring this check when the final portion of the filename is + * pathless, we will still detect path collisions such as: + * + * routes/parent._pathless.foo.tsx + * routes/parent._pathless2.foo.tsx + * + * and + * + * routes/parent._pathless/index.tsx + * routes/parent._pathless2/index.tsx + */ + let lastRouteSegment = config.id + .replace(new RegExp(`^${prefix}/`), "") + .split(".") + .pop(); + let isPathlessLayoutRoute = + lastRouteSegment && + lastRouteSegment.startsWith("_") && + lastRouteSegment !== "_index"; + if (isPathlessLayoutRoute) { + continue; + } + + let conflictRouteId = originalPathname + (config.index ? "?index" : ""); + let conflict = uniqueRoutes.get(conflictRouteId); + uniqueRoutes.set(conflictRouteId, config); + + if (conflict && (originalPathname || config.index)) { + let currentConflicts = urlConflicts.get(originalPathname); + if (!currentConflicts) currentConflicts = [conflict]; + currentConflicts.push(config); + urlConflicts.set(originalPathname, currentConflicts); + continue; + } + } + + if (routeIdConflicts.size > 0) { + for (let [routeId, files] of routeIdConflicts.entries()) { + console.error(getRouteIdConflictErrorMessage(routeId, files)); + } + } + + // report conflicts + if (urlConflicts.size > 0) { + for (let [path, routes] of urlConflicts.entries()) { + // delete all but the first route from the manifest + for (let i = 1; i < routes.length; i++) { + delete routeManifest[routes[i].id]; + } + let files = routes.map((r) => r.file); + console.error(getRoutePathConflictErrorMessage(path, files)); + } + } + + return routeManifest; +} + +export function normalizeSlashes(file: string) { + return file.split("\\").join("/"); +} + +type State = + | // normal path segment normal character concatenation until we hit a special character or the end of the segment (i.e. `/`, `.`, '\') + "NORMAL" + // we hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks + | "ESCAPE" + // we hit a `(` and are now in an optional segment until we hit a `)` or an escape sequence + | "OPTIONAL" + // we previously were in a opt fional segment and hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - afterwards go back to OPTIONAL state + | "OPTIONAL_ESCAPE"; + +export function getRouteSegments(routeId: string): [string[], string[]] { + let routeSegments: string[] = []; + let rawRouteSegments: string[] = []; + let index = 0; + let routeSegment = ""; + let rawRouteSegment = ""; + let state: State = "NORMAL"; + + let pushRouteSegment = (segment: string, rawSegment: string) => { + if (!segment) return; + + let notSupportedInRR = (segment: string, char: string) => { + throw new Error( + `Route segment "${segment}" for "${routeId}" cannot contain "${char}".\n` + + `If this is something you need, upvote this proposal for React Router https://github.com/remix-run/react-router/discussions/9822.` + ); + }; + + if (rawSegment.includes("*")) { + return notSupportedInRR(rawSegment, "*"); + } + + if (rawSegment.includes(":")) { + return notSupportedInRR(rawSegment, ":"); + } + + if (rawSegment.includes("/")) { + return notSupportedInRR(segment, "/"); + } + + routeSegments.push(segment); + rawRouteSegments.push(rawSegment); + }; + + while (index < routeId.length) { + let char = routeId[index]; + index++; //advance to next char + + switch (state) { + case "NORMAL": { + if (isSegmentSeparator(char)) { + pushRouteSegment(routeSegment, rawRouteSegment); + routeSegment = ""; + rawRouteSegment = ""; + state = "NORMAL"; + break; + } + if (char === escapeStart) { + state = "ESCAPE"; + rawRouteSegment += char; + break; + } + if (char === optionalStart) { + state = "OPTIONAL"; + rawRouteSegment += char; + break; + } + if (!routeSegment && char == paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "ESCAPE": { + if (char === escapeEnd) { + state = "NORMAL"; + rawRouteSegment += char; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL": { + if (char === optionalEnd) { + routeSegment += "?"; + rawRouteSegment += char; + state = "NORMAL"; + break; + } + + if (char === escapeStart) { + state = "OPTIONAL_ESCAPE"; + rawRouteSegment += char; + break; + } + + if (!routeSegment && char === paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL_ESCAPE": { + if (char === escapeEnd) { + state = "OPTIONAL"; + rawRouteSegment += char; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + } + } + + // process remaining segment + pushRouteSegment(routeSegment, rawRouteSegment); + return [routeSegments, rawRouteSegments]; +} + +export function createRoutePath( + routeSegments: string[], + rawRouteSegments: string[], + isIndex?: boolean +) { + let result: string[] = []; + + if (isIndex) { + routeSegments = routeSegments.slice(0, -1); + } + + for (let index = 0; index < routeSegments.length; index++) { + let segment = routeSegments[index]; + let rawSegment = rawRouteSegments[index]; + + // skip pathless layout segments + if (segment.startsWith("_") && rawSegment.startsWith("_")) { + continue; + } + + // remove trailing slash + if (segment.endsWith("_") && rawSegment.endsWith("_")) { + segment = segment.slice(0, -1); + } + + result.push(segment); + } + + return result.length ? result.join("/") : undefined; +} + +export function getRoutePathConflictErrorMessage( + pathname: string, + routes: string[] +) { + let [taken, ...others] = routes; + + if (!pathname.startsWith("/")) { + pathname = "/" + pathname; + } + + return ( + `⚠️ Route Path Collision: "${pathname}"\n\n` + + `The following routes all define the same URL, only the first one will be used\n\n` + + `🟢 ${taken}\n` + + others.map((route) => `⭕️️ ${route}`).join("\n") + + "\n" + ); +} + +export function getRouteIdConflictErrorMessage( + routeId: string, + files: string[] +) { + let [taken, ...others] = files; + + return ( + `⚠️ Route ID Collision: "${routeId}"\n\n` + + `The following routes all define the same Route ID, only the first one will be used\n\n` + + `🟢 ${taken}\n` + + others.map((route) => `⭕️️ ${route}`).join("\n") + + "\n" + ); +} + +export function isSegmentSeparator(checkChar: string | undefined) { + if (!checkChar) return false; + return ["/", ".", "\\"].includes(checkChar); +} + +const PrefixLookupTrieEndSymbol = Symbol("PrefixLookupTrieEndSymbol"); +type PrefixLookupNode = { + [key: string]: PrefixLookupNode; +} & Record; + +class PrefixLookupTrie { + root: PrefixLookupNode = { + [PrefixLookupTrieEndSymbol]: false, + }; + + add(value: string) { + if (!value) throw new Error("Cannot add empty string to PrefixLookupTrie"); + + let node = this.root; + for (let char of value) { + if (!node[char]) { + node[char] = { + [PrefixLookupTrieEndSymbol]: false, + }; + } + node = node[char]; + } + node[PrefixLookupTrieEndSymbol] = true; + } + + findAndRemove( + prefix: string, + filter: (nodeValue: string) => boolean + ): string[] { + let node = this.root; + for (let char of prefix) { + if (!node[char]) return []; + node = node[char]; + } + + return this.#findAndRemoveRecursive([], node, prefix, filter); + } + + #findAndRemoveRecursive( + values: string[], + node: PrefixLookupNode, + prefix: string, + filter: (nodeValue: string) => boolean + ): string[] { + for (let char of Object.keys(node)) { + this.#findAndRemoveRecursive(values, node[char], prefix + char, filter); + } + + if (node[PrefixLookupTrieEndSymbol] && filter(prefix)) { + node[PrefixLookupTrieEndSymbol] = false; + values.push(prefix); + } + + return values; + } +} + +function pathJoin(a: string, b: string) { + return a + "/" + b; +} + +function pathRelative(a: string, b: string) { + return b.replace(a, ""); +} diff --git a/examples/ssr-single-fetch/app/global.css b/examples/ssr-single-fetch/app/global.css new file mode 100644 index 0000000000..3e1f253f03 --- /dev/null +++ b/examples/ssr-single-fetch/app/global.css @@ -0,0 +1,12 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; +} diff --git a/examples/ssr-single-fetch/app/render-to-readable-stream.node.ts b/examples/ssr-single-fetch/app/render-to-readable-stream.node.ts new file mode 100644 index 0000000000..140661f019 --- /dev/null +++ b/examples/ssr-single-fetch/app/render-to-readable-stream.node.ts @@ -0,0 +1,83 @@ +import { PassThrough, Readable } from "node:stream"; +// @ts-expect-error - no types +import ReactDOMServer from "react-dom/server.node"; +import type { + renderToPipeableStream as renderToPipeableStreamType, + renderToReadableStream as renderToReadableStreamType, + ReactDOMServerReadableStream, +} from "react-dom/server"; + +class Deferred { + promise: Promise; + // @ts-expect-error - no initializer + resolve: (value: T) => void; + // @ts-expect-error - no initializer + reject: (reason?: unknown) => void; + done = false; + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (value) => { + if (this.done) return; + this.done = true; + resolve(value); + }; + this.reject = (reason) => { + if (this.done) return; + this.done = true; + reject(reason); + }; + }); + } +} + +const renderToPipeableStream = + ReactDOMServer.renderToPipeableStream as typeof renderToPipeableStreamType; + +export const renderToReadableStream: typeof renderToReadableStreamType = async ( + element, + options +) => { + const { signal, ...rest } = options ?? {}; + + const shellReady = new Deferred(); + const allReady = new Deferred(); + + // If nobody ever awaits this promise, this line will prevent an UnhandledPromiseRejection error + // from showing up in the console. + allReady.promise.catch(() => {}); + + const { pipe, abort } = renderToPipeableStream(element, { + ...rest, + onShellReady() { + shellReady.resolve(readable); + }, + onAllReady() { + allReady.resolve(); + }, + onShellError(error) { + allReady.reject(error); + shellReady.reject(error); + }, + }); + + let startedFlowing = false; + const passthrough = new PassThrough({ + read() { + if (!startedFlowing) { + startedFlowing = true; + pipe(passthrough); + } + }, + }); + + if (signal) { + signal.addEventListener("abort", abort, { once: true }); + } + + const readable = Readable.toWeb( + passthrough + ) as unknown as ReactDOMServerReadableStream; + readable.allReady = allReady.promise; + + return shellReady.promise; +}; diff --git a/examples/ssr-single-fetch/app/routes/_layout._index/route.tsx b/examples/ssr-single-fetch/app/routes/_layout._index/route.tsx new file mode 100644 index 0000000000..447d90d6d8 --- /dev/null +++ b/examples/ssr-single-fetch/app/routes/_layout._index/route.tsx @@ -0,0 +1,18 @@ +import { useLoaderData } from "react-router-dom"; + +export function loader() { + return { + message: "This is a loader message.", + }; +} + +export function Component() { + const { message } = useLoaderData() as Awaited>; + + return ( +
+

Index

+

{message}

+
+ ); +} diff --git a/examples/ssr-single-fetch/app/routes/_layout.about/route.tsx b/examples/ssr-single-fetch/app/routes/_layout.about/route.tsx new file mode 100644 index 0000000000..33f64e1a04 --- /dev/null +++ b/examples/ssr-single-fetch/app/routes/_layout.about/route.tsx @@ -0,0 +1,7 @@ +export function Component() { + return ( +
+

About

+
+ ); +} diff --git a/examples/ssr-single-fetch/app/routes/_layout/route.tsx b/examples/ssr-single-fetch/app/routes/_layout/route.tsx new file mode 100644 index 0000000000..acd74bb8db --- /dev/null +++ b/examples/ssr-single-fetch/app/routes/_layout/route.tsx @@ -0,0 +1,51 @@ +import { + Link, + Outlet, + isRouteErrorResponse, + useRouteError, +} from "react-router-dom"; + +function Nav() { + return ( + + ); +} + +export function Component() { + return ( + + + +