From 29a1e3065ebe4154197f657a33b8f88f16698f13 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 6 Feb 2024 15:08:00 -0500 Subject: [PATCH 01/57] Bump to RR experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 76c59f8fa9f..090b52f8144 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "1.15.0", + "@remix-run/router": "0.0.0-experimental-bc2c864b", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 5e671359059..156814196d1 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "1.15.0", + "@remix-run/router": "0.0.0-experimental-bc2c864b", "@remix-run/server-runtime": "2.6.0", - "react-router": "6.22.0", - "react-router-dom": "6.22.0" + "react-router": "0.0.0-experimental-bc2c864b", + "react-router-dom": "0.0.0-experimental-bc2c864b" }, "devDependencies": { "@testing-library/jest-dom": "^5.17.0", diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index e2a22aea123..b602d15f4c8 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "1.15.0", + "@remix-run/router": "0.0.0-experimental-bc2c864b", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index 0cdb2496a1b..c24481fb56a 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "1.15.0", - "react-router-dom": "6.22.0" + "@remix-run/router": "0.0.0-experimental-bc2c864b", + "react-router-dom": "0.0.0-experimental-bc2c864b" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 4b7aa4b63a4..4e30912c361 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@1.15.0": - version "1.15.0" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.15.0.tgz#461a952c2872dd82c8b2e9b74c4dfaff569123e2" - integrity sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ== +"@remix-run/router@0.0.0-experimental-bc2c864b": + version "0.0.0-experimental-bc2c864b" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-bc2c864b.tgz#3ab6a900128fd4fcf625058440ee5654d439c9ab" + integrity sha512-NXfQVZA1qCqpJyX4zsDxZtXYf3AmKvuhPY7MAKIUDrNoJl+MO+gwIEJDYzznW4DDFgP58uQ5Jz3Bbu93JZ6TqQ== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@6.22.0: - version "6.22.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.0.tgz#177c8bd27146decbb991eafb5df159f7a9f70035" - integrity sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag== +react-router-dom@0.0.0-experimental-bc2c864b: + version "0.0.0-experimental-bc2c864b" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-bc2c864b.tgz#098ff77872a50d0347d6f5894d63eb6c00a2b7b1" + integrity sha512-i4erXztigPdGqyvf1SU3XULg4NEOw2bV3Rd/ckITl7XJJlhFS8iuG6G7Uz4VgxQFTKeT1m64APMc28EQJlFv3g== dependencies: - "@remix-run/router" "1.15.0" - react-router "6.22.0" + "@remix-run/router" "0.0.0-experimental-bc2c864b" + react-router "0.0.0-experimental-bc2c864b" -react-router@6.22.0: - version "6.22.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-6.22.0.tgz#a22b44851a79dafc6b944cb418db3e80622b9be1" - integrity sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg== +react-router@0.0.0-experimental-bc2c864b: + version "0.0.0-experimental-bc2c864b" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-bc2c864b.tgz#55cb4fa3dc11770167f1f8901413cb422c538932" + integrity sha512-rS1RaBthiQ5RSKV4LB3hcxhQHDOHkkpq3AgdQCYlxzun+0nqu7aZ1KdPP0Wga0gDdHmxYBcX5Wqt4DUXGx3rjw== dependencies: - "@remix-run/router" "1.15.0" + "@remix-run/router" "0.0.0-experimental-bc2c864b" react@^18.2.0: version "18.2.0" From 78c011e998c8bbeb0f7fe67ea690b0c13bca69fe Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 6 Feb 2024 15:08:34 -0500 Subject: [PATCH 02/57] Initial implementation of single fetch for loaders --- packages/remix-react/browser.tsx | 50 ++++++++++++++ packages/remix-react/data.ts | 2 +- packages/remix-server-runtime/server.ts | 86 +++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 357edc655d9..00ab4fbd979 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,6 +1,7 @@ import { createBrowserHistory, createRouter, + ResultType, type HydrationState, type Router, } from "@remix-run/router"; @@ -278,6 +279,39 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { }, hydrationData, mapRouteProperties, + async unstable_dataStrategy({ request, matches }) { + let routeDeferreds = new Map< + string, + ReturnType + >(); + + let routePromises = matches.map((m) => + m.bikeshed_loadRoute(async () => { + let dfd = createDeferred(); + routeDeferreds.set(m.route.id, dfd); + return dfd.promise; + }) + ); + + // TODO: action requests + // TODO: granular revalidation + + let url = new URL(request.url); + url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; + let data = await fetch(url).then((r) => r.json()); + + routeDeferreds.forEach((dfd, routeId) => { + if (data.loaderData[routeId] !== undefined) { + dfd.resolve(data.loaderData[routeId]); + } else if (data.errors && data.errors[routeId] !== undefined) { + dfd.reject(data.errors[routeId]); + } else { + dfd.reject(new Error(`No response found for routeId "${routeId}"`)); + } + }); + + return Promise.all(routePromises); + }, }); // We can call initialize() immediately if the router doesn't have any @@ -359,3 +393,19 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { ); } + +export function createDeferred() { + let resolve: (val?: any) => Promise; + let reject: (error?: Error) => Promise; + let promise = new Promise((res, rej) => { + resolve = async (val: T) => res(val); + reject = async (error?: Error) => rej(error); + }); + return { + promise, + //@ts-ignore + resolve, + //@ts-ignore + reject, + }; +} diff --git a/packages/remix-react/data.ts b/packages/remix-react/data.ts index eb4d4c5f520..d6e80f42bf4 100644 --- a/packages/remix-react/data.ts +++ b/packages/remix-react/data.ts @@ -20,7 +20,7 @@ export function isNetworkErrorResponse(response: any): response is Response { // If we reach the Remix server, we can safely identify response types via the // X-Remix-Error/X-Remix-Catch headers. However, if we never reach the Remix // server, and instead receive a 4xx/5xx from somewhere in between (like - // Cloudflare), then we get a false negative n the isErrorResponse check and + // Cloudflare), then we get a false negative in the isErrorResponse check and // we incorrectly assume that the user returns the 4xx/5xx response and // consider it successful. To alleviate this, we add X-Remix-Response to any // non-Error/non-Catch responses coming back from the server. If we don't diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index ab5683ed784..e324fbb71c0 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -28,6 +28,7 @@ import { createDeferredReadableStream, isRedirectResponse, isResponse, + json, } from "./responses"; import { createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; @@ -119,7 +120,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( if (url.searchParams.has("_data")) { let routeId = url.searchParams.get("_data")!; - response = await handleDataRequestRR( + response = await handleDataRequest( serverMode, _build, staticHandler, @@ -136,12 +137,30 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( request, }); } + } else if (url.pathname.endsWith(".data")) { + response = await handleSingleFetchRequest( + serverMode, + _build, + staticHandler, + url, + loadContext, + handleError + ); + + // TODO: + // if (_build.entry.module.handleDataRequest) { + // response = await _build.entry.module.handleDataRequest(response, { + // context: loadContext, + // params: matches?.find((m) => m.route.id == routeId)?.params || {}, + // request, + // }); + // } } else if ( matches && matches[matches.length - 1].route.module.default == null && matches[matches.length - 1].route.module.ErrorBoundary == null ) { - response = await handleResourceRequestRR( + response = await handleResourceRequest( serverMode, staticHandler, matches.slice(-1)[0].route.id, @@ -155,7 +174,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( ? await getDevServerHooks()?.getCriticalCss?.(_build, url.pathname) : undefined; - response = await handleDocumentRequestRR( + response = await handleDocumentRequest( serverMode, _build, staticHandler, @@ -178,7 +197,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( }; }; -async function handleDataRequestRR( +async function handleDataRequest( serverMode: ServerMode, build: ServerBuild, staticHandler: StaticHandler, @@ -265,7 +284,62 @@ async function handleDataRequestRR( } } -async function handleDocumentRequestRR( +async function handleSingleFetchRequest( + serverMode: ServerMode, + build: ServerBuild, + staticHandler: StaticHandler, + url: URL, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +) { + let context; + try { + let handlerUrl = new URL(url); + handlerUrl.pathname = handlerUrl.pathname + .replace(/\.data$/, "") + .replace(/^\/_root$/, "/"); + context = await staticHandler.query(new Request(handlerUrl), { + requestContext: loadContext, + }); + } catch (error: unknown) { + handleError(error); + return new Response(null, { status: 500 }); + } + + if (isResponse(context)) { + return context; + } + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + // TODO: Handle deferred + + let headers = getDocumentHeadersRR(build, context); + + // Mark all successful responses with a header so we can identify in-flight + // network errors that are missing this header + headers.set("X-Remix-Response", "yes"); + + return json( + { + actionData: context.actionData, + loaderData: context.loaderData, + errors: context.errors, + }, + { headers } + ); +} + +async function handleDocumentRequest( serverMode: ServerMode, build: ServerBuild, staticHandler: StaticHandler, @@ -409,7 +483,7 @@ async function handleDocumentRequestRR( } } -async function handleResourceRequestRR( +async function handleResourceRequest( serverMode: ServerMode, staticHandler: StaticHandler, routeId: string, From e83749946d626166ec2abd561f613fcd9f36e080 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 6 Feb 2024 17:39:36 -0500 Subject: [PATCH 03/57] Add future flag --- packages/remix-dev/config.ts | 2 ++ packages/remix-react/browser.tsx | 22 +++++++++++++------- packages/remix-react/entry.ts | 1 + packages/remix-server-runtime/entry.ts | 1 + packages/remix-server-runtime/server.ts | 21 +++++++++++-------- packages/remix-testing/create-remix-stub.tsx | 1 + 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 45a3920caad..ac576095f40 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -37,6 +37,7 @@ interface FutureConfig { v3_fetcherPersist: boolean; v3_relativeSplatPath: boolean; v3_throwAbortReason: boolean; + unstable_singleFetch: boolean; } type NodeBuiltinsPolyfillOptions = Pick< @@ -600,6 +601,7 @@ export async function resolveConfig( v3_fetcherPersist: appConfig.future?.v3_fetcherPersist === true, v3_relativeSplatPath: appConfig.future?.v3_relativeSplatPath === true, v3_throwAbortReason: appConfig.future?.v3_throwAbortReason === true, + unstable_singleFetch: appConfig.future?.unstable_singleFetch === true, }; if (appConfig.future) { diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 00ab4fbd979..391f53849b2 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,10 +1,8 @@ -import { - createBrowserHistory, - createRouter, - ResultType, - type HydrationState, - type Router, +import type { + HydrationState, + Router, } from "@remix-run/router"; +import { createBrowserHistory, createRouter } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; @@ -279,6 +277,8 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { }, hydrationData, mapRouteProperties, + ...(window.__remixContext.future.unstable_singleFetch + ? { async unstable_dataStrategy({ request, matches }) { let routeDeferreds = new Map< string, @@ -297,7 +297,9 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { // TODO: granular revalidation let url = new URL(request.url); - url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; + url.pathname = `${ + url.pathname === "/" ? "_root" : url.pathname + }.data`; let data = await fetch(url).then((r) => r.json()); routeDeferreds.forEach((dfd, routeId) => { @@ -306,12 +308,16 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { } else if (data.errors && data.errors[routeId] !== undefined) { dfd.reject(data.errors[routeId]); } else { - dfd.reject(new Error(`No response found for routeId "${routeId}"`)); + dfd.reject( + new Error(`No response found for routeId "${routeId}"`) + ); } }); return Promise.all(routePromises); }, + } + : {}), }); // We can call initialize() immediately if the router doesn't have any diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index a3366cc451d..9360ee42cf1 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -29,6 +29,7 @@ export interface EntryContext extends RemixContextObject { export interface FutureConfig { v3_fetcherPersist: boolean; v3_relativeSplatPath: boolean; + unstable_singleFetch: boolean; } export interface AssetsManifest { diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts index 6d73dcad51b..75debe07361 100644 --- a/packages/remix-server-runtime/entry.ts +++ b/packages/remix-server-runtime/entry.ts @@ -19,6 +19,7 @@ export interface FutureConfig { v3_fetcherPersist: boolean; v3_relativeSplatPath: boolean; v3_throwAbortReason: boolean; + unstable_singleFetch: boolean; } export interface AssetsManifest { diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index e324fbb71c0..ee10d8a7a88 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -18,7 +18,7 @@ import type { HandleErrorFunction, ServerBuild } from "./build"; import type { EntryContext } from "./entry"; import { createEntryRouteModules } from "./entry"; import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; -import { getDocumentHeadersRR } from "./headers"; +import { getDocumentHeadersRR as getDocumentHeaders } from "./headers"; import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; import { matchServerRoutes } from "./routeMatching"; @@ -28,7 +28,6 @@ import { createDeferredReadableStream, isRedirectResponse, isResponse, - json, } from "./responses"; import { createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; @@ -137,7 +136,10 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( request, }); } - } else if (url.pathname.endsWith(".data")) { + } else if ( + _build.future.unstable_singleFetch && + url.pathname.endsWith(".data") + ) { response = await handleSingleFetchRequest( serverMode, _build, @@ -291,7 +293,7 @@ async function handleSingleFetchRequest( url: URL, loadContext: AppLoadContext, handleError: (err: unknown) => void -) { +): Promise { let context; try { let handlerUrl = new URL(url); @@ -323,18 +325,19 @@ async function handleSingleFetchRequest( // TODO: Handle deferred - let headers = getDocumentHeadersRR(build, context); + let headers = getDocumentHeaders(build, context); + headers.set("Content-Type", "application/json"); // Mark all successful responses with a header so we can identify in-flight // network errors that are missing this header headers.set("X-Remix-Response", "yes"); - return json( - { + return new Response( + JSON.stringify({ actionData: context.actionData, loaderData: context.loaderData, errors: context.errors, - }, + }), { headers } ); } @@ -373,7 +376,7 @@ async function handleDocumentRequest( context.errors = sanitizeErrors(context.errors, serverMode); } - let headers = getDocumentHeadersRR(build, context); + let headers = getDocumentHeaders(build, context); let entryContext: EntryContext = { manifest: build.assets, diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx index e9f763256a1..0c19d2fb1a1 100644 --- a/packages/remix-testing/create-remix-stub.tsx +++ b/packages/remix-testing/create-remix-stub.tsx @@ -106,6 +106,7 @@ export function createRemixStub( future: { v3_fetcherPersist: future?.v3_fetcherPersist === true, v3_relativeSplatPath: future?.v3_relativeSplatPath === true, + unstable_singleFetch: future?.unstable_singleFetch === true, }, manifest: { routes: {}, From 05ef1decc8285c19e808bf48eb846a1b64b1745d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 6 Feb 2024 17:40:01 -0500 Subject: [PATCH 04/57] Bump RR experimental to allow boolean loaders --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 090b52f8144..d8ebcad7f6a 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-bc2c864b", + "@remix-run/router": "0.0.0-experimental-a0888892", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 156814196d1..c585e834193 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-bc2c864b", + "@remix-run/router": "0.0.0-experimental-a0888892", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-bc2c864b", - "react-router-dom": "0.0.0-experimental-bc2c864b" + "react-router": "0.0.0-experimental-a0888892", + "react-router-dom": "0.0.0-experimental-a0888892" }, "devDependencies": { "@testing-library/jest-dom": "^5.17.0", diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index b602d15f4c8..c22071923f6 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-bc2c864b", + "@remix-run/router": "0.0.0-experimental-a0888892", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index c24481fb56a..eb277e2e021 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-bc2c864b", - "react-router-dom": "0.0.0-experimental-bc2c864b" + "@remix-run/router": "0.0.0-experimental-a0888892", + "react-router-dom": "0.0.0-experimental-a0888892" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 4e30912c361..6e781a8e4fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-bc2c864b": - version "0.0.0-experimental-bc2c864b" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-bc2c864b.tgz#3ab6a900128fd4fcf625058440ee5654d439c9ab" - integrity sha512-NXfQVZA1qCqpJyX4zsDxZtXYf3AmKvuhPY7MAKIUDrNoJl+MO+gwIEJDYzznW4DDFgP58uQ5Jz3Bbu93JZ6TqQ== +"@remix-run/router@0.0.0-experimental-a0888892": + version "0.0.0-experimental-a0888892" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-a0888892.tgz#8c7bdbb05f35c839bea6cccf9e38e513167f8a85" + integrity sha512-sq4MivCFFjsHuSjwK5TCBqZzEgKTGNtuG6ykM+7InjtA+rEIt+tpnulai7HKbe9+fS4rDMnL5riWR+SOzCwMkg== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-bc2c864b: - version "0.0.0-experimental-bc2c864b" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-bc2c864b.tgz#098ff77872a50d0347d6f5894d63eb6c00a2b7b1" - integrity sha512-i4erXztigPdGqyvf1SU3XULg4NEOw2bV3Rd/ckITl7XJJlhFS8iuG6G7Uz4VgxQFTKeT1m64APMc28EQJlFv3g== +react-router-dom@0.0.0-experimental-a0888892: + version "0.0.0-experimental-a0888892" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-a0888892.tgz#34108386ec62fde554444974a61123d1189cc86f" + integrity sha512-EkMqTRzw+JA5ic3+kUztrFgux99XocPt3vtA+QzqOgfZL71UuUYbslfEKraXOyhFZIIYsFcHAfUA7LV3jCqTHg== dependencies: - "@remix-run/router" "0.0.0-experimental-bc2c864b" - react-router "0.0.0-experimental-bc2c864b" + "@remix-run/router" "0.0.0-experimental-a0888892" + react-router "0.0.0-experimental-a0888892" -react-router@0.0.0-experimental-bc2c864b: - version "0.0.0-experimental-bc2c864b" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-bc2c864b.tgz#55cb4fa3dc11770167f1f8901413cb422c538932" - integrity sha512-rS1RaBthiQ5RSKV4LB3hcxhQHDOHkkpq3AgdQCYlxzun+0nqu7aZ1KdPP0Wga0gDdHmxYBcX5Wqt4DUXGx3rjw== +react-router@0.0.0-experimental-a0888892: + version "0.0.0-experimental-a0888892" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-a0888892.tgz#f1f62988c9d75c68dd44b6f3dc91131b755e7e3b" + integrity sha512-XUTRKQhuHShOw+V6Nm05Cdv3FzJP6e3lOPTd4Cvdj+TyL6epCgnDr6TdH8HoGG4Swq/6LaiNPv8DtSiB++XzZg== dependencies: - "@remix-run/router" "0.0.0-experimental-bc2c864b" + "@remix-run/router" "0.0.0-experimental-a0888892" react@^18.2.0: version "18.2.0" From 59758b3c746244a567b7c6f1611f961160bc5161 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 6 Feb 2024 17:46:50 -0500 Subject: [PATCH 05/57] Handle clientLoaders with single fetch enabled --- packages/remix-react/browser.tsx | 148 +++++++++++++++++++------------ packages/remix-react/routes.tsx | 25 ++++++ 2 files changed, 118 insertions(+), 55 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 391f53849b2..f9c13de7ce1 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,17 +1,25 @@ import type { + StaticHandlerContext, HydrationState, Router, + DataStrategyFunction, } from "@remix-run/router"; import { createBrowserHistory, createRouter } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; +import type { + DataStrategyFunctionArgs, + DataStrategyMatch, +} from "react-router-dom"; import { matchRoutes, RouterProvider } from "react-router-dom"; import { RemixContext } from "./components"; import type { EntryContext, FutureConfig } from "./entry"; import { RemixErrorBoundary } from "./errorBoundaries"; import { deserializeErrors } from "./errors"; +import invariant from "./invariant"; +import { prefetchStyleLinks } from "./links"; import type { RouteModules } from "./routeModules"; import { createClientRoutes, @@ -277,47 +285,9 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { }, hydrationData, mapRouteProperties, - ...(window.__remixContext.future.unstable_singleFetch - ? { - async unstable_dataStrategy({ request, matches }) { - let routeDeferreds = new Map< - string, - ReturnType - >(); - - let routePromises = matches.map((m) => - m.bikeshed_loadRoute(async () => { - let dfd = createDeferred(); - routeDeferreds.set(m.route.id, dfd); - return dfd.promise; - }) - ); - - // TODO: action requests - // TODO: granular revalidation - - let url = new URL(request.url); - url.pathname = `${ - url.pathname === "/" ? "_root" : url.pathname - }.data`; - let data = await fetch(url).then((r) => r.json()); - - routeDeferreds.forEach((dfd, routeId) => { - if (data.loaderData[routeId] !== undefined) { - dfd.resolve(data.loaderData[routeId]); - } else if (data.errors && data.errors[routeId] !== undefined) { - dfd.reject(data.errors[routeId]); - } else { - dfd.reject( - new Error(`No response found for routeId "${routeId}"`) - ); - } - }); - - return Promise.all(routePromises); - }, - } - : {}), + unstable_dataStrategy: window.__remixContext.future.unstable_singleFetch + ? singleFetchDataStrategy + : undefined, }); // We can call initialize() immediately if the router doesn't have any @@ -400,18 +370,86 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { ); } -export function createDeferred() { - let resolve: (val?: any) => Promise; - let reject: (error?: Error) => Promise; - let promise = new Promise((res, rej) => { - resolve = async (val: T) => res(val); - reject = async (error?: Error) => rej(error); - }); - return { - promise, - //@ts-ignore - resolve, - //@ts-ignore - reject, - }; +async function singleFetchDataStrategy({ + request, + matches, +}: DataStrategyFunctionArgs) { + // let routeDeferreds = new Map>(); + + // Prefetch styles for matched routes that exist in the routeModulesCache + // (critical modules and navigating back to pages previously loaded via + // route.lazy). Initial execution of route.lazy (when the module is not in + // the cache) will handle prefetching style links via loadRouteModuleWithBlockingLinks. + let stylesPromise = Promise.all( + matches.map((m) => { + let route = window.__remixManifest.routes[m.route.id]; + let cachedModule = window.__remixRouteModules[m.route.id]; + return cachedModule + ? prefetchStyleLinks(route, cachedModule) + : Promise.resolve(); + }) + ); + + // TODO: Critical route modules for single fetch + // TODO: action requests + // TODO: granular revalidation + // TODO: Fix issue with auto-revalidating routes on HMR + // - load / + // - navigate to /parent/child + // - trigger HMR + // - back button to / + // - throws a "you returned undefined from a loader" error + + // Create a singular promise for all routes to latch onto for single fetch. + // This way we can kick off `clientLoaders` and ensure: + // 1. we only call the server if at least one of them calls `serverLoader` + // 2. if multiple call` serverLoader` only one fetch call is made + let singleFetchPromise: Promise< + Pick + >; + async function singleFetch(routeId: string) { + if (!singleFetchPromise) { + let url = new URL(request.url); + url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; + singleFetchPromise = fetch(url).then((r) => r.json()); + } + let data = await singleFetchPromise; + if (data.loaderData[routeId] !== undefined) { + return data.loaderData[routeId]; + } else if (data.errors && data.errors[routeId] !== undefined) { + throw data.errors[routeId]; + } else { + throw new Error(`No response found for routeId "${routeId}"`); + } + } + + let routePromise = Promise.all( + matches.map((m) => + m.bikeshed_loadRoute((handler) => { + let route = window.__remixManifest.routes[m.route.id]; + let routeModule = window.__remixRouteModules[m.route.id]; + invariant( + routeModule, + "Expected a defined routeModule after bikeshed_loadRoute" + ); + if (routeModule.clientLoader) { + return routeModule.clientLoader({ + request, + params: m.params, + serverLoader: () => singleFetch(m.route.id), + }); + } else if (route.hasLoader) { + return singleFetch(m.route.id); + } else { + // If we make it into the `bikeshed_loadRoute` callback we ought to + // have a handler to call so this shouldn't happen but I think some + // HMR/HDR scenarios might hit this flow? + return Promise.resolve(undefined); + } + }) + ) + ); + + let [routeData] = await Promise.all([routePromise, stylesPromise]); + return routeData; } diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 8975c093a27..f3ac62145d4 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -380,6 +380,31 @@ export function createClientRoutes( }); }); }; + } else if (future.unstable_singleFetch) { + dataRoute.lazy = async () => { + let mod = await loadRouteModuleWithBlockingLinks( + route, + routeModulesCache + ); + + return { + // We just need booleans here when single fetch is enabled to get them + // into `matchesToLoad` - we'll handle the rest of it in `dataStrategy` + loader: route.hasLoader || route.hasClientLoader, + action: route.hasAction || route.hasClientAction, + hasErrorBoundary: mod.ErrorBoundary !== undefined, + shouldRevalidate: needsRevalidation + ? wrapShouldRevalidateForHdr( + route.id, + mod.shouldRevalidate, + needsRevalidation + ) + : mod.shouldRevalidate, + handle: mod.handle, + Component: mod.Component, + ErrorBoundary: mod.ErrorBoundary, + }; + }; } else { // If the lazy route does not have a client loader/action we want to call // the server loader/action in parallel with the module load so we add From 1c5978b7d96677bb8d14946d0dac7b6e0e9bc191 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 8 Feb 2024 15:30:24 -0500 Subject: [PATCH 06/57] Use turbo-stream for single fetch responses --- packages/remix-react/browser.tsx | 31 +++--- packages/remix-react/package.json | 3 +- packages/remix-server-runtime/package.json | 3 +- packages/remix-server-runtime/server.ts | 30 +++++- yarn.lock | 111 ++++++++++++--------- 5 files changed, 114 insertions(+), 64 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index f9c13de7ce1..32abb6b7d7b 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -2,17 +2,14 @@ import type { StaticHandlerContext, HydrationState, Router, - DataStrategyFunction, } from "@remix-run/router"; import { createBrowserHistory, createRouter } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; -import type { - DataStrategyFunctionArgs, - DataStrategyMatch, -} from "react-router-dom"; +import type { DataStrategyFunctionArgs } from "react-router-dom"; import { matchRoutes, RouterProvider } from "react-router-dom"; +import { decode } from "turbo-stream"; import { RemixContext } from "./components"; import type { EntryContext, FutureConfig } from "./entry"; @@ -374,8 +371,6 @@ async function singleFetchDataStrategy({ request, matches, }: DataStrategyFunctionArgs) { - // let routeDeferreds = new Map>(); - // Prefetch styles for matched routes that exist in the routeModulesCache // (critical modules and navigating back to pages previously loaded via // route.lazy). Initial execution of route.lazy (when the module is not in @@ -407,11 +402,23 @@ async function singleFetchDataStrategy({ let singleFetchPromise: Promise< Pick >; - async function singleFetch(routeId: string) { + let sharedSingleFetch = async (routeId: string) => { if (!singleFetchPromise) { let url = new URL(request.url); url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; - singleFetchPromise = fetch(url).then((r) => r.json()); + singleFetchPromise = fetch(url).then(async (res) => { + invariant( + res.headers.get("Content-Type")?.includes("text/x-turbo"), + "Expected a text/x-turbo response" + ); + let decoded = await decode(res.body!); + let value = decoded.value as Pick< + StaticHandlerContext, + "actionData" | "loaderData" | "errors" + >; + + return value; + }); } let data = await singleFetchPromise; if (data.loaderData[routeId] !== undefined) { @@ -421,7 +428,7 @@ async function singleFetchDataStrategy({ } else { throw new Error(`No response found for routeId "${routeId}"`); } - } + }; let routePromise = Promise.all( matches.map((m) => @@ -436,10 +443,10 @@ async function singleFetchDataStrategy({ return routeModule.clientLoader({ request, params: m.params, - serverLoader: () => singleFetch(m.route.id), + serverLoader: () => sharedSingleFetch(m.route.id), }); } else if (route.hasLoader) { - return singleFetch(m.route.id); + return sharedSingleFetch(m.route.id); } else { // If we make it into the `bikeshed_loadRoute` callback we ought to // have a handler to call so this shouldn't happen but I think some diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index c585e834193..918ad3e0644 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -19,7 +19,8 @@ "@remix-run/router": "0.0.0-experimental-a0888892", "@remix-run/server-runtime": "2.6.0", "react-router": "0.0.0-experimental-a0888892", - "react-router-dom": "0.0.0-experimental-a0888892" + "react-router-dom": "0.0.0-experimental-a0888892", + "turbo-stream": "^1.2.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.17.0", diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index c22071923f6..538ead047ca 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -21,7 +21,8 @@ "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3" + "source-map": "^0.7.3", + "turbo-stream": "^1.2.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index ee10d8a7a88..2cf857d08f8 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -12,6 +12,7 @@ import { stripBasename, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, } from "@remix-run/router"; +import { encode } from "turbo-stream"; import type { AppLoadContext } from "./data"; import type { HandleErrorFunction, ServerBuild } from "./build"; @@ -321,19 +322,38 @@ async function handleSingleFetchRequest( } }); context.errors = sanitizeErrors(context.errors, serverMode); - } - // TODO: Handle deferred + // TODO: Feels hacky - we need to un-bubble errors here since they'll be + // bubbled client side. Probably better to throw a flag on query() to not + // do this in the first place + let mostRecentError: [string, unknown] | null = null; + for (let match of context.matches) { + let routeId = match.route.id; + if (context.errors[routeId] !== undefined) { + mostRecentError = [routeId, context.errors[routeId]]; + } + if ( + build.assets.routes[routeId]?.hasLoader && + context.loaderData[routeId] === undefined + ) { + invariant(mostRecentError, "Expected mostRecentError to be set"); + context.errors[mostRecentError[0]] = undefined; + context.errors[routeId] = mostRecentError[1]; + mostRecentError = null; + } + } + } let headers = getDocumentHeaders(build, context); - headers.set("Content-Type", "application/json"); - // Mark all successful responses with a header so we can identify in-flight // network errors that are missing this header headers.set("X-Remix-Response", "yes"); + headers.set("Content-Type", "text/x-turbo"); + // Note: Deferred data is already just Promises on context.loaderData, so we + // don't have to mess with context.activeDeferreds or anything :) return new Response( - JSON.stringify({ + encode({ actionData: context.actionData, loaderData: context.loaderData, errors: context.errors, diff --git a/yarn.lock b/yarn.lock index 6e781a8e4fe..7cd47e371d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3578,17 +3578,25 @@ accepts@^1.3.7, accepts@~1.3.5, accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" +acorn-globals@^7.0.0: + version "7.0.1" + resolved "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" + integrity sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q== + dependencies: + acorn "^8.1.0" + acorn-walk "^8.0.2" + acorn-jsx@^5.0.0, acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.2.0: +acorn-walk@^8.0.2, acorn-walk@^8.2.0: version "8.3.2" resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^8.0.0, acorn@^8.8.0, acorn@^8.8.1: +acorn@^8.0.0, acorn@^8.1.0, acorn@^8.8.0, acorn@^8.8.1: version "8.11.3" resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -5026,12 +5034,22 @@ cssesc@^3.0.0: resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -cssstyle@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a" - integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg== +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + +cssom@~0.3.6: + version "0.3.8" + resolved "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" + integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== + +cssstyle@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" + integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== dependencies: - rrweb-cssom "^0.6.0" + cssom "~0.3.6" csstype@^3.0.2, csstype@^3.0.7: version "3.1.1" @@ -5138,14 +5156,14 @@ data-uri-to-buffer@^5.0.1: resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-5.0.1.tgz#db89a9e279c2ffe74f50637a59a32fb23b3e4d7c" integrity sha512-a9l6T1qqDogvvnw0nKlfZzqsyikEBZBClF39V3TFoKhDtGBqHu2HkuomJc02j5zft8zrUaXEuoicLeW54RkzPg== -data-urls@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" - integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g== +data-urls@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" + integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== dependencies: abab "^2.0.6" whatwg-mimetype "^3.0.0" - whatwg-url "^12.0.0" + whatwg-url "^11.0.0" dataloader@^1.4.0: version "1.4.0" @@ -5196,7 +5214,7 @@ decamelize@^1.1.0, decamelize@^1.2.0: resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -decimal.js@^10.4.3: +decimal.js@^10.4.2: version "10.4.3" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== @@ -5798,7 +5816,7 @@ escape-string-regexp@^5.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -escodegen@^2.1.0: +escodegen@^2.0.0, escodegen@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== @@ -8351,24 +8369,27 @@ jsbn@~0.1.0: resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= -jsdom@^20.0.0, jsdom@^22.0.0: - version "22.1.0" - resolved "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz#0fca6d1a37fbeb7f4aac93d1090d782c56b611c8" - integrity sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw== +jsdom@^20.0.0: + version "20.0.3" + resolved "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db" + integrity sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ== dependencies: abab "^2.0.6" - cssstyle "^3.0.0" - data-urls "^4.0.0" - decimal.js "^10.4.3" + acorn "^8.8.1" + acorn-globals "^7.0.0" + cssom "^0.5.0" + cssstyle "^2.3.0" + data-urls "^3.0.2" + decimal.js "^10.4.2" domexception "^4.0.0" + escodegen "^2.0.0" form-data "^4.0.0" html-encoding-sniffer "^3.0.0" http-proxy-agent "^5.0.0" https-proxy-agent "^5.0.1" is-potential-custom-element-name "^1.0.1" - nwsapi "^2.2.4" - parse5 "^7.1.2" - rrweb-cssom "^0.6.0" + nwsapi "^2.2.2" + parse5 "^7.1.1" saxes "^6.0.0" symbol-tree "^3.2.4" tough-cookie "^4.1.2" @@ -8376,8 +8397,8 @@ jsdom@^20.0.0, jsdom@^22.0.0: webidl-conversions "^7.0.0" whatwg-encoding "^2.0.0" whatwg-mimetype "^3.0.0" - whatwg-url "^12.0.1" - ws "^8.13.0" + whatwg-url "^11.0.0" + ws "^8.11.0" xml-name-validator "^4.0.0" jsesc@3.0.2: @@ -10310,7 +10331,7 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" -nwsapi@^2.2.4: +nwsapi@^2.2.2: version "2.2.7" resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== @@ -10655,7 +10676,7 @@ parse5-htmlparser2-tree-adapter@^7.0.0: domhandler "^5.0.2" parse5 "^7.0.0" -parse5@^7.0.0, parse5@^7.1.2: +parse5@^7.0.0, parse5@^7.1.1: version "7.1.2" resolved "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== @@ -11189,7 +11210,7 @@ pumpify@^1.3.3: inherits "^2.0.3" pump "^2.0.0" -punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -11801,11 +11822,6 @@ rollup@^4.2.0: "@rollup/rollup-win32-x64-msvc" "4.4.1" fsevents "~2.3.2" -rrweb-cssom@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" - integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== - run-async@^2.4.0: version "2.4.1" resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz" @@ -12834,12 +12850,12 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" -tr46@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" - integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== dependencies: - punycode "^2.3.0" + punycode "^2.1.1" tr46@~0.0.3: version "0.0.3" @@ -12937,6 +12953,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +turbo-stream@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/turbo-stream/-/turbo-stream-1.2.0.tgz#1388dd457d94970e11832c92475d5264d652049e" + integrity sha512-aunXYgJ3hcqutvmtZ/aZWpWsNZGFiMp+Yw29Z6w0jnH69wrCLzsAO6RR6PI6ivY9tq9PdwlyxHY2WBvlYm8jzA== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz" @@ -13567,12 +13588,12 @@ whatwg-mimetype@^3.0.0: resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== -whatwg-url@^12.0.0, whatwg-url@^12.0.1: - version "12.0.1" - resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c" - integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ== +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== dependencies: - tr46 "^4.1.1" + tr46 "^3.0.0" webidl-conversions "^7.0.0" whatwg-url@^5.0.0: @@ -13750,7 +13771,7 @@ ws@^7.4.5: resolved "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz" integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A== -ws@^8.11.0, ws@^8.13.0: +ws@^8.11.0: version "8.16.0" resolved "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== From 195cd18d0b04539c22a001f8c9e4d95a31bdb7a1 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 8 Feb 2024 18:02:40 -0500 Subject: [PATCH 07/57] POC of streamiong loader data down in action response --- packages/remix-react/browser.tsx | 148 +++++++++++++---- packages/remix-react/routes.tsx | 2 +- packages/remix-server-runtime/server.ts | 203 +++++++++++++++++++----- 3 files changed, 278 insertions(+), 75 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 32abb6b7d7b..c449f479c1a 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,8 +1,9 @@ import type { - StaticHandlerContext, HydrationState, Router, + DataStrategyMatch, } from "@remix-run/router"; +import type { SerializeFrom } from "@remix-run/server-runtime"; import { createBrowserHistory, createRouter } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; @@ -21,6 +22,7 @@ import type { RouteModules } from "./routeModules"; import { createClientRoutes, createClientRoutesWithHMRRevalidationOptOut, + noActionDefinedError, shouldHydrateRouteLoader, } from "./routes"; @@ -367,10 +369,23 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { ); } +type SingleFetchResult = { data: unknown } | { error: unknown }; +type SingleFetchResults = { + action?: SingleFetchResult; + loaders: Record; +}; + +// TODO: This is temporary just tio get it woring for one action at a time. +// We need to extend this via some form of global serverRoundTripId from the +// router that applies to navigations and fetches +let revalidationPromise: Promise | null = null; + async function singleFetchDataStrategy({ request, matches, }: DataStrategyFunctionArgs) { + // TODO: Do styles load twice on actions? + // Prefetch styles for matched routes that exist in the routeModulesCache // (critical modules and navigating back to pages previously loaded via // route.lazy). Initial execution of route.lazy (when the module is not in @@ -385,8 +400,17 @@ async function singleFetchDataStrategy({ }) ); + if (request.method !== "GET") { + let routePromise = singleFetchAction(request, matches); + let [routeData] = await Promise.all([routePromise, stylesPromise]); + return routeData; + } else { + let routePromise = singleFetchLoaders(request, matches); + let [routeData] = await Promise.all([routePromise, stylesPromise]); + return routeData; + } + // TODO: Critical route modules for single fetch - // TODO: action requests // TODO: granular revalidation // TODO: Fix issue with auto-revalidating routes on HMR // - load / @@ -394,69 +418,129 @@ async function singleFetchDataStrategy({ // - trigger HMR // - back button to / // - throws a "you returned undefined from a loader" error +} + +async function makeSingleFetchCall(request: Request) { + let url = new URL(request.url); + url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; + let res = await fetch(url, { method: request.method }); + invariant( + res.headers.get("Content-Type")?.includes("text/x-turbo"), + "Expected a text/x-turbo response" + ); + let decoded = await decode(res.body!); + return decoded.value as SingleFetchResults; +} + +function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { + let singleFetch = async (routeId: string) => { + let data = await makeSingleFetchCall(request); + if (data.action === undefined) { + throw new Error(`No action response found`); + } + // Stash off streaming loader data promise for the subsequent router + // revalidation loader executions + if (data.loaders instanceof Promise) { + revalidationPromise = data.loaders; + } + + if ("error" in data.action) { + throw data.action.error; + } else if ("data" in data.action) { + return data.action.data; + } else { + throw new Error(`No action response found for routeId "${routeId}"`); + } + }; + + return Promise.all( + matches.map((m) => + m.bikeshed_loadRoute(() => { + let route = window.__remixManifest.routes[m.route.id]; + let routeModule = window.__remixRouteModules[m.route.id]; + invariant( + routeModule, + "Expected a defined routeModule after bikeshed_loadRoute" + ); + + if (routeModule.clientAction) { + return routeModule.clientAction({ + request, + params: m.params, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint + serverAction: () => + singleFetch(m.route.id) as Promise>, + }); + } else if (route.hasAction) { + return singleFetch(m.route.id); + } else { + throw noActionDefinedError("action", m.route.id); + } + }) + ) + ); +} + +function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { // Create a singular promise for all routes to latch onto for single fetch. // This way we can kick off `clientLoaders` and ensure: // 1. we only call the server if at least one of them calls `serverLoader` // 2. if multiple call` serverLoader` only one fetch call is made - let singleFetchPromise: Promise< - Pick - >; + let singleFetchPromise: Promise; let sharedSingleFetch = async (routeId: string) => { if (!singleFetchPromise) { - let url = new URL(request.url); - url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; - singleFetchPromise = fetch(url).then(async (res) => { - invariant( - res.headers.get("Content-Type")?.includes("text/x-turbo"), - "Expected a text/x-turbo response" - ); - let decoded = await decode(res.body!); - let value = decoded.value as Pick< - StaticHandlerContext, - "actionData" | "loaderData" | "errors" - >; - - return value; - }); + // If this is a revalidation for a prior action and we already got the data - use it + if (revalidationPromise) { + singleFetchPromise = revalidationPromise.then((loaders) => ({ + loaders, + })); + revalidationPromise = null; + } else { + singleFetchPromise = makeSingleFetchCall(request); + } } let data = await singleFetchPromise; - if (data.loaderData[routeId] !== undefined) { - return data.loaderData[routeId]; - } else if (data.errors && data.errors[routeId] !== undefined) { - throw data.errors[routeId]; + let routeData = data.loaders[routeId]; + if ("error" in routeData) { + throw routeData?.error; + } else if ("data" in routeData) { + return routeData?.data; } else { - throw new Error(`No response found for routeId "${routeId}"`); + throw new Error(`No loader response found for routeId "${routeId}"`); } }; - let routePromise = Promise.all( + return Promise.all( matches.map((m) => - m.bikeshed_loadRoute((handler) => { + m.bikeshed_loadRoute(() => { let route = window.__remixManifest.routes[m.route.id]; let routeModule = window.__remixRouteModules[m.route.id]; invariant( routeModule, "Expected a defined routeModule after bikeshed_loadRoute" ); + if (routeModule.clientLoader) { return routeModule.clientLoader({ request, params: m.params, - serverLoader: () => sharedSingleFetch(m.route.id), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint + serverLoader: () => + sharedSingleFetch(m.route.id) as Promise>, }); } else if (route.hasLoader) { return sharedSingleFetch(m.route.id); } else { + // TODO: We seem to get here for routes without a loader - + // they should get short circuited! + // If we make it into the `bikeshed_loadRoute` callback we ought to // have a handler to call so this shouldn't happen but I think some // HMR/HDR scenarios might hit this flow? - return Promise.resolve(undefined); + return Promise.resolve(null); } }) ) ); - - let [routeData] = await Promise.all([routePromise, stylesPromise]); - return routeData; } diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index f3ac62145d4..b089789fad6 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -221,7 +221,7 @@ function preventInvalidServerHandlerCall( } } -function noActionDefinedError( +export function noActionDefinedError( type: "action" | "clientAction", routeId: string ) { diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 2cf857d08f8..97b46390219 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -2,6 +2,7 @@ import type { UNSAFE_DeferredData as DeferredData, ErrorResponse, StaticHandler, + StaticHandlerContext, } from "@remix-run/router"; import { UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, @@ -145,7 +146,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( serverMode, _build, staticHandler, - url, + request, loadContext, handleError ); @@ -287,30 +288,153 @@ async function handleDataRequest( } } +type SingleFetchResult = + | { data: unknown } + | { error: unknown } + | { redirect: string }; +type SingleFetchResults = { + action?: SingleFetchResult; + loaders: + | Record + | Promise>; +}; + async function handleSingleFetchRequest( serverMode: ServerMode, build: ServerBuild, staticHandler: StaticHandler, - url: URL, + request: Request, loadContext: AppLoadContext, handleError: (err: unknown) => void ): Promise { - let context; + let handlerUrl = new URL(request.url); + handlerUrl.pathname = handlerUrl.pathname + .replace(/\.data$/, "") + .replace(/^\/_root$/, "/"); + + if (request.method !== "GET") { + let { action, headers } = await singleFetchAction( + request, + handlerUrl, + staticHandler, + loadContext, + handleError + ); + // Mark all successful responses with a header so we can identify in-flight + // network errors that are missing this header + headers.set("X-Remix-Response", "yes"); + headers.set("Content-Type", "text/x-turbo"); + + let result: SingleFetchResults = { + action, + loaders: singleFetchLoaders( + handlerUrl, + staticHandler, + loadContext, + handleError, + serverMode, + build + ).then(({ loaders }) => loaders), + }; + // Note: Deferred data is already just Promises on context.loaderData, so we + // don't have to mess with context.activeDeferreds or anything :) + return new Response(encode(result), { headers }); + } + + let { loaders, headers } = await singleFetchLoaders( + handlerUrl, + staticHandler, + loadContext, + handleError, + serverMode, + build + ); + + // Mark all successful responses with a header so we can identify in-flight + // network errors that are missing this header + headers.set("X-Remix-Response", "yes"); + headers.set("Content-Type", "text/x-turbo"); + + let result: SingleFetchResults = { + loaders, + }; + // Note: Deferred data is already just Promises on context.loaderData, so we + // don't have to mess with context.activeDeferreds or anything :) + return new Response(encode(result), { headers }); +} + +async function singleFetchAction( + request: Request, + handlerUrl: URL, + staticHandler: StaticHandler, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +): Promise<{ action: SingleFetchResults["action"]; headers: Headers }> { try { - let handlerUrl = new URL(url); - handlerUrl.pathname = handlerUrl.pathname - .replace(/\.data$/, "") - .replace(/^\/_root$/, "/"); - context = await staticHandler.query(new Request(handlerUrl), { + let handlerRequest = new Request(handlerUrl, { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + ...(request.body ? { duplex: "half" } : undefined), + }); + let response = await staticHandler.queryRoute(handlerRequest, { requestContext: loadContext, + // TODO: Will need to send this in a header or something + // routeId: }); - } catch (error: unknown) { + // callRouteLoader/callRouteAction always return responses + invariant( + isResponse(response), + "Expected a Response to be returned from queryRoute" + ); + if (isRedirectResponse(response)) { + return { + action: { redirect: response.headers.get("Location")! }, + headers: response.headers, + }; + } + return { + action: { data: await unwrapResponse(response) }, + headers: response.headers, + }; + } catch (error) { handleError(error); - return new Response(null, { status: 500 }); + return { + action: { error }, + headers: new Headers(), + }; } +} - if (isResponse(context)) { - return context; +async function singleFetchLoaders( + handlerUrl: URL, + staticHandler: StaticHandler, + loadContext: AppLoadContext, + handleError: (err: unknown) => void, + serverMode: ServerMode, + build: ServerBuild +): Promise<{ loaders: SingleFetchResults["loaders"]; headers: Headers }> { + let context: StaticHandlerContext; + try { + let handlerRequest = new Request(handlerUrl); + let result = await staticHandler.query(handlerRequest, { + requestContext: loadContext, + }); + if (isResponse(result)) { + // TODO: What's the use-case that lands us here? + return { + loaders: { root: { redirect: result.headers.get("Location")! } }, + headers: result.headers, + }; + } + context = result; + } catch (error: unknown) { + handleError(error); + return { + loaders: { root: { error } }, + headers: new Headers(), + }; } // Sanitize errors outside of development environments @@ -344,22 +468,19 @@ async function handleSingleFetchRequest( } } - let headers = getDocumentHeaders(build, context); - // Mark all successful responses with a header so we can identify in-flight - // network errors that are missing this header - headers.set("X-Remix-Response", "yes"); - headers.set("Content-Type", "text/x-turbo"); - - // Note: Deferred data is already just Promises on context.loaderData, so we - // don't have to mess with context.activeDeferreds or anything :) - return new Response( - encode({ - actionData: context.actionData, - loaderData: context.loaderData, - errors: context.errors, - }), - { headers } - ); + return { + loaders: context.matches.reduce( + (acc, match) => + Object.assign(acc, { + [match.route.id]: + context.errors?.[match.route.id] !== undefined + ? { error: context.errors[match.route.id] } + : { data: context.loaderData[match.route.id] }, + }), + {} + ), + headers: getDocumentHeaders(build, context), + }; } async function handleDocumentRequest( @@ -437,21 +558,8 @@ async function handleDocumentRequest( // If they threw a response, unwrap it into an ErrorResponse like we would // have for a loader/action if (isResponse(error)) { - let data; try { - let contentType = error.headers.get("Content-Type"); - // Check between word boundaries instead of startsWith() due to the last - // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type - if (contentType && /\bapplication\/json\b/.test(contentType)) { - if (error.body == null) { - data = null; - } else { - data = await error.json(); - } - } else { - data = await error.text(); - } - + let data = await unwrapResponse(error); errorForSecondRender = new ErrorResponseImpl( error.status, error.statusText, @@ -588,3 +696,14 @@ function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) { }, }); } + +function unwrapResponse(response: Response) { + let contentType = response.headers.get("Content-Type"); + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + return contentType && /\bapplication\/json\b/.test(contentType) + ? response.body == null + ? null + : response.json() + : response.text(); +} From cc8a03fa9f46b4d547108cb63b2363fd328c85f0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 9 Feb 2024 13:28:24 -0500 Subject: [PATCH 08/57] Move back to separate action and revalidation requests --- packages/remix-react/browser.tsx | 81 ++++++------ packages/remix-server-runtime/server.ts | 163 ++++++++++++------------ 2 files changed, 119 insertions(+), 125 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index c449f479c1a..3ae5d1eb998 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -4,7 +4,11 @@ import type { DataStrategyMatch, } from "@remix-run/router"; import type { SerializeFrom } from "@remix-run/server-runtime"; -import { createBrowserHistory, createRouter } from "@remix-run/router"; +import { + createBrowserHistory, + createRouter, + redirect, +} from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; @@ -369,16 +373,18 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { ); } -type SingleFetchResult = { data: unknown } | { error: unknown }; +type SingleFetchResult = + | { data: unknown } + | { error: unknown } + | { redirect: string; status: number; revalidate: boolean; reload: boolean }; type SingleFetchResults = { - action?: SingleFetchResult; - loaders: Record; + [key: string]: SingleFetchResult; }; -// TODO: This is temporary just tio get it woring for one action at a time. +// TODO: This is temporary just tio get it working for one action at a time. // We need to extend this via some form of global serverRoundTripId from the // router that applies to navigations and fetches -let revalidationPromise: Promise | null = null; +//let revalidationPromise: Promise | null = null; async function singleFetchDataStrategy({ request, @@ -429,29 +435,32 @@ async function makeSingleFetchCall(request: Request) { "Expected a text/x-turbo response" ); let decoded = await decode(res.body!); - return decoded.value as SingleFetchResults; + return decoded.value; } -function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { - let singleFetch = async (routeId: string) => { - let data = await makeSingleFetchCall(request); - if (data.action === undefined) { - throw new Error(`No action response found`); +function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { + if ("error" in result) { + throw result.error; + } else if ("redirect" in result) { + let headers: Record = {}; + if (result.revalidate) { + headers["X-Remix-Revalidate"] = "yes"; } - - // Stash off streaming loader data promise for the subsequent router - // revalidation loader executions - if (data.loaders instanceof Promise) { - revalidationPromise = data.loaders; + if (result.reload) { + headers["X-Remix-Reload-Document"] = "yes"; } + return redirect(result.redirect, { status: result.status, headers }); + } else if ("data" in result) { + return result.data; + } else { + throw new Error(`No action response found for routeId "${routeId}"`); + } +} - if ("error" in data.action) { - throw data.action.error; - } else if ("data" in data.action) { - return data.action.data; - } else { - throw new Error(`No action response found for routeId "${routeId}"`); - } +function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { + let singleFetch = async (routeId: string) => { + let result = (await makeSingleFetchCall(request)) as SingleFetchResult; + return unwrapSingleFetchResult(result, routeId); }; return Promise.all( @@ -490,25 +499,15 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { let singleFetchPromise: Promise; let sharedSingleFetch = async (routeId: string) => { if (!singleFetchPromise) { - // If this is a revalidation for a prior action and we already got the data - use it - if (revalidationPromise) { - singleFetchPromise = revalidationPromise.then((loaders) => ({ - loaders, - })); - revalidationPromise = null; - } else { - singleFetchPromise = makeSingleFetchCall(request); - } + singleFetchPromise = makeSingleFetchCall( + request + ) as Promise; } - let data = await singleFetchPromise; - let routeData = data.loaders[routeId]; - if ("error" in routeData) { - throw routeData?.error; - } else if ("data" in routeData) { - return routeData?.data; - } else { - throw new Error(`No loader response found for routeId "${routeId}"`); + let results = await singleFetchPromise; + if (results[routeId] !== undefined) { + return unwrapSingleFetchResult(results[routeId], routeId); } + return null; }; return Promise.all( diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 97b46390219..29f2899c289 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -23,8 +23,8 @@ import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; import { getDocumentHeadersRR as getDocumentHeaders } from "./headers"; import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; -import { matchServerRoutes } from "./routeMatching"; -import type { ServerRoute } from "./routes"; +import { RouteMatch, matchServerRoutes } from "./routeMatching"; +import type { Route, ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createDeferredReadableStream, @@ -142,11 +142,24 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( _build.future.unstable_singleFetch && url.pathname.endsWith(".data") ) { + let handlerUrl = new URL(request.url); + handlerUrl.pathname = handlerUrl.pathname + .replace(/\.data$/, "") + .replace(/^\/_root$/, "/"); + + let matches = matchServerRoutes( + routes, + handlerUrl.pathname, + _build.basename + ); + response = await handleSingleFetchRequest( serverMode, _build, staticHandler, + matches, request, + handlerUrl, loadContext, handleError ); @@ -291,76 +304,49 @@ async function handleDataRequest( type SingleFetchResult = | { data: unknown } | { error: unknown } - | { redirect: string }; + | { redirect: string; status: number; revalidate: boolean; reload: boolean }; type SingleFetchResults = { - action?: SingleFetchResult; - loaders: - | Record - | Promise>; + [key: string]: SingleFetchResult; }; async function handleSingleFetchRequest( serverMode: ServerMode, build: ServerBuild, staticHandler: StaticHandler, + matches: RouteMatch[] | null, request: Request, + handlerUrl: URL, loadContext: AppLoadContext, handleError: (err: unknown) => void ): Promise { - let handlerUrl = new URL(request.url); - handlerUrl.pathname = handlerUrl.pathname - .replace(/\.data$/, "") - .replace(/^\/_root$/, "/"); - - if (request.method !== "GET") { - let { action, headers } = await singleFetchAction( - request, - handlerUrl, - staticHandler, - loadContext, - handleError - ); - // Mark all successful responses with a header so we can identify in-flight - // network errors that are missing this header - headers.set("X-Remix-Response", "yes"); - headers.set("Content-Type", "text/x-turbo"); - - let result: SingleFetchResults = { - action, - loaders: singleFetchLoaders( - handlerUrl, - staticHandler, - loadContext, - handleError, - serverMode, - build - ).then(({ loaders }) => loaders), - }; - // Note: Deferred data is already just Promises on context.loaderData, so we - // don't have to mess with context.activeDeferreds or anything :) - return new Response(encode(result), { headers }); - } - - let { loaders, headers } = await singleFetchLoaders( - handlerUrl, - staticHandler, - loadContext, - handleError, - serverMode, - build - ); + let [result, headers] = + request.method !== "GET" + ? await singleFetchAction( + request, + handlerUrl, + staticHandler, + loadContext, + handleError + ) + : await singleFetchLoaders( + handlerUrl, + staticHandler, + matches, + loadContext, + handleError, + serverMode, + build + ); // Mark all successful responses with a header so we can identify in-flight // network errors that are missing this header - headers.set("X-Remix-Response", "yes"); - headers.set("Content-Type", "text/x-turbo"); + let resultHeaders = new Headers(headers); + resultHeaders.set("X-Remix-Response", "yes"); + resultHeaders.set("Content-Type", "text/x-turbo"); - let result: SingleFetchResults = { - loaders, - }; - // Note: Deferred data is already just Promises on context.loaderData, so we - // don't have to mess with context.activeDeferreds or anything :) - return new Response(encode(result), { headers }); + // Note: Deferred data is already just Promises, so we don't have to mess + // `activeDeferreds` or anything :) + return new Response(encode(result), { headers: resultHeaders }); } async function singleFetchAction( @@ -369,7 +355,7 @@ async function singleFetchAction( staticHandler: StaticHandler, loadContext: AppLoadContext, handleError: (err: unknown) => void -): Promise<{ action: SingleFetchResults["action"]; headers: Headers }> { +): Promise<[SingleFetchResult, Headers]> { try { let handlerRequest = new Request(handlerUrl, { method: request.method, @@ -389,32 +375,32 @@ async function singleFetchAction( "Expected a Response to be returned from queryRoute" ); if (isRedirectResponse(response)) { - return { - action: { redirect: response.headers.get("Location")! }, - headers: response.headers, - }; + return [ + { + redirect: response.headers.get("Location")!, + status: response.status, + revalidate: response.headers.has("X-Remix-Revalidate"), + reload: response.headers.has("X-Remix-Reload-Document"), + }, + response.headers, + ]; } - return { - action: { data: await unwrapResponse(response) }, - headers: response.headers, - }; + return [{ data: await unwrapResponse(response) }, response.headers]; } catch (error) { handleError(error); - return { - action: { error }, - headers: new Headers(), - }; + return [{ error }, new Headers()]; } } async function singleFetchLoaders( handlerUrl: URL, staticHandler: StaticHandler, + matches: RouteMatch[] | null, loadContext: AppLoadContext, handleError: (err: unknown) => void, serverMode: ServerMode, build: ServerBuild -): Promise<{ loaders: SingleFetchResults["loaders"]; headers: Headers }> { +): Promise<[SingleFetchResults, Headers]> { let context: StaticHandlerContext; try { let handlerRequest = new Request(handlerUrl); @@ -422,19 +408,28 @@ async function singleFetchLoaders( requestContext: loadContext, }); if (isResponse(result)) { - // TODO: What's the use-case that lands us here? - return { - loaders: { root: { redirect: result.headers.get("Location")! } }, - headers: result.headers, - }; + // We don't really know which loader this came from, so just stick it at + // a known match + // TODO: this should take into account the revalidation header + console.log(matches); + let routeId = + matches?.find((m) => m.route.module.loader)?.route.id || "root"; + return [ + { + [routeId]: { + redirect: result.headers.get("Location")!, + status: result.status, + revalidate: result.headers.has("X-Remix-Revalidate"), + reload: result.headers.has("X-Remix-Reload-Document"), + }, + }, + result.headers, + ]; } context = result; } catch (error: unknown) { handleError(error); - return { - loaders: { root: { error } }, - headers: new Headers(), - }; + return [{ root: { error } }, new Headers()]; } // Sanitize errors outside of development environments @@ -468,8 +463,8 @@ async function singleFetchLoaders( } } - return { - loaders: context.matches.reduce( + return [ + context.matches.reduce( (acc, match) => Object.assign(acc, { [match.route.id]: @@ -479,8 +474,8 @@ async function singleFetchLoaders( }), {} ), - headers: getDocumentHeaders(build, context), - }; + getDocumentHeaders(build, context), + ]; } async function handleDocumentRequest( From 918d3402bd4f43733704621107691b69e8a85115 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 9 Feb 2024 17:07:01 -0500 Subject: [PATCH 09/57] WIP POC of granular revalidation --- packages/remix-react/browser.tsx | 54 +++++++++++++++++++++++-- packages/remix-server-runtime/server.ts | 1 - 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 3ae5d1eb998..85eb7fb93c3 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -381,6 +381,8 @@ type SingleFetchResults = { [key: string]: SingleFetchResult; }; +let isRevalidation = false; + // TODO: This is temporary just tio get it working for one action at a time. // We need to extend this via some form of global serverRoundTripId from the // router that applies to navigations and fetches @@ -411,13 +413,17 @@ async function singleFetchDataStrategy({ let [routeData] = await Promise.all([routePromise, stylesPromise]); return routeData; } else { - let routePromise = singleFetchLoaders(request, matches); + // Single fetch doesn't need/want naked index queries on action + // revalidation requests + let routePromise = singleFetchLoaders(stripIndexParam(request), matches); let [routeData] = await Promise.all([routePromise, stylesPromise]); return routeData; } // TODO: Critical route modules for single fetch // TODO: granular revalidation + // TODO: Don't revalidate on action 4xx/5xx responses with status codes + // (return or throw) // TODO: Fix issue with auto-revalidating routes on HMR // - load / // - navigate to /parent/child @@ -426,10 +432,23 @@ async function singleFetchDataStrategy({ // - throws a "you returned undefined from a loader" error } -async function makeSingleFetchCall(request: Request) { +async function makeSingleFetchCall( + request: Request, + revalidatingRoutes?: Set +) { + if (revalidatingRoutes) { + await new Promise((r) => setTimeout(r, 0)); + } let url = new URL(request.url); url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; - let res = await fetch(url, { method: request.method }); + let res = await fetch(url, { + method: request.method, + headers: { + ...(revalidatingRoutes + ? { "X-Remix-Revalidate": Array.from(revalidatingRoutes).join(",") } + : {}), + }, + }); invariant( res.headers.get("Content-Type")?.includes("text/x-turbo"), "Expected a text/x-turbo response" @@ -463,6 +482,8 @@ function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { return unwrapSingleFetchResult(result, routeId); }; + isRevalidation = true; + return Promise.all( matches.map((m) => m.bikeshed_loadRoute(() => { @@ -492,6 +513,7 @@ function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { } function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { + let revalidatingRoutes = new Set(); // Create a singular promise for all routes to latch onto for single fetch. // This way we can kick off `clientLoaders` and ensure: // 1. we only call the server if at least one of them calls `serverLoader` @@ -500,8 +522,13 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { let sharedSingleFetch = async (routeId: string) => { if (!singleFetchPromise) { singleFetchPromise = makeSingleFetchCall( - request + request, + isRevalidation ? revalidatingRoutes : undefined ) as Promise; + // TODO: Pass this in from dataStrategy + // Maybe we can even just throw revalidationRequired or something on + // `DataStrategyMatch` and avoid all this await tick() stuff... + isRevalidation = false; } let results = await singleFetchPromise; if (results[routeId] !== undefined) { @@ -513,6 +540,8 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { return Promise.all( matches.map((m) => m.bikeshed_loadRoute(() => { + console.log("Inside loadRoute callback for route ", m.route.id); + revalidatingRoutes.add(m.route.id); let route = window.__remixManifest.routes[m.route.id]; let routeModule = window.__remixRouteModules[m.route.id]; invariant( @@ -543,3 +572,20 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { ) ); } + +function stripIndexParam(request: Request) { + let url = new URL(request.url); + let indexValues = url.searchParams.getAll("index"); + url.searchParams.delete("index"); + let indexValuesToKeep = []; + for (let indexValue of indexValues) { + if (indexValue) { + indexValuesToKeep.push(indexValue); + } + } + for (let toKeep of indexValuesToKeep) { + url.searchParams.append("index", toKeep); + } + + return new Request(url.href); +} diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 29f2899c289..733d64b17a1 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -411,7 +411,6 @@ async function singleFetchLoaders( // We don't really know which loader this came from, so just stick it at // a known match // TODO: this should take into account the revalidation header - console.log(matches); let routeId = matches?.find((m) => m.route.module.loader)?.route.id || "root"; return [ From 18378f1ce84c9fec80857a0e97c30a6b4fb022a4 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 13 Feb 2024 12:24:57 -0500 Subject: [PATCH 10/57] Support fine-grained revalidation --- packages/remix-react/browser.tsx | 217 +++++++++++++----------- packages/remix-react/routes.tsx | 2 +- packages/remix-server-runtime/server.ts | 37 ++-- 3 files changed, 143 insertions(+), 113 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 85eb7fb93c3..412155da08a 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -26,8 +26,11 @@ import type { RouteModules } from "./routeModules"; import { createClientRoutes, createClientRoutesWithHMRRevalidationOptOut, - noActionDefinedError, shouldHydrateRouteLoader, + // TODO: Eventually we should move the single fetch stuff to data.ts and + // stop exporting these + noActionDefinedError, + preventInvalidServerHandlerCall, } from "./routes"; /* eslint-disable prefer-let/prefer-let */ @@ -381,13 +384,6 @@ type SingleFetchResults = { [key: string]: SingleFetchResult; }; -let isRevalidation = false; - -// TODO: This is temporary just tio get it working for one action at a time. -// We need to extend this via some form of global serverRoundTripId from the -// router that applies to navigations and fetches -//let revalidationPromise: Promise | null = null; - async function singleFetchDataStrategy({ request, matches, @@ -408,20 +404,15 @@ async function singleFetchDataStrategy({ }) ); - if (request.method !== "GET") { - let routePromise = singleFetchAction(request, matches); - let [routeData] = await Promise.all([routePromise, stylesPromise]); - return routeData; - } else { - // Single fetch doesn't need/want naked index queries on action - // revalidation requests - let routePromise = singleFetchLoaders(stripIndexParam(request), matches); - let [routeData] = await Promise.all([routePromise, stylesPromise]); - return routeData; - } + let dataPromise = + request.method === "GET" + ? singleFetchLoaders(request, matches) + : singleFetchAction(request, matches); + + let [routeData] = await Promise.all([dataPromise, stylesPromise]); + return routeData; // TODO: Critical route modules for single fetch - // TODO: granular revalidation // TODO: Don't revalidate on action 4xx/5xx responses with status codes // (return or throw) // TODO: Fix issue with auto-revalidating routes on HMR @@ -432,58 +423,20 @@ async function singleFetchDataStrategy({ // - throws a "you returned undefined from a loader" error } -async function makeSingleFetchCall( - request: Request, - revalidatingRoutes?: Set -) { - if (revalidatingRoutes) { - await new Promise((r) => setTimeout(r, 0)); - } - let url = new URL(request.url); - url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; - let res = await fetch(url, { - method: request.method, - headers: { - ...(revalidatingRoutes - ? { "X-Remix-Revalidate": Array.from(revalidatingRoutes).join(",") } - : {}), - }, - }); - invariant( - res.headers.get("Content-Type")?.includes("text/x-turbo"), - "Expected a text/x-turbo response" - ); - let decoded = await decode(res.body!); - return decoded.value; -} - -function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { - if ("error" in result) { - throw result.error; - } else if ("redirect" in result) { - let headers: Record = {}; - if (result.revalidate) { - headers["X-Remix-Revalidate"] = "yes"; - } - if (result.reload) { - headers["X-Remix-Reload-Document"] = "yes"; - } - return redirect(result.redirect, { status: result.status, headers }); - } else if ("data" in result) { - return result.data; - } else { - throw new Error(`No action response found for routeId "${routeId}"`); - } -} - function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { let singleFetch = async (routeId: string) => { - let result = (await makeSingleFetchCall(request)) as SingleFetchResult; + let res = await fetch(singleFetchUrl(request.url), { + method: request.method, + }); + invariant( + res.headers.get("Content-Type")?.includes("text/x-turbo"), + "Expected a text/x-turbo response" + ); + let decoded = await decode(res.body!); + let result = decoded.value as SingleFetchResult; return unwrapSingleFetchResult(result, routeId); }; - isRevalidation = true; - return Promise.all( matches.map((m) => m.bikeshed_loadRoute(() => { @@ -498,9 +451,14 @@ function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { return routeModule.clientAction({ request, params: m.params, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint - serverAction: () => - singleFetch(m.route.id) as Promise>, + serverAction() { + preventInvalidServerHandlerCall( + "action", + route, + window.__remixContext.isSpaMode + ); + return singleFetch(m.route.id) as Promise>; + }, }); } else if (route.hasAction) { return singleFetch(m.route.id); @@ -513,22 +471,56 @@ function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { } function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { - let revalidatingRoutes = new Set(); // Create a singular promise for all routes to latch onto for single fetch. // This way we can kick off `clientLoaders` and ensure: // 1. we only call the server if at least one of them calls `serverLoader` // 2. if multiple call` serverLoader` only one fetch call is made let singleFetchPromise: Promise; - let sharedSingleFetch = async (routeId: string) => { + + let makeSingleFetchCall = async () => { + // Single fetch doesn't need/want naked index queries on action + // revalidation requests + let url = singleFetchUrl(stripIndexParam(request.url)); + + // Determine which routes we want to load so we can send an X-Remix-Routes header + // for fine-grained revalidation if necessary. If a route has not yet been loaded + // via `route.lazy` then we know we want to load it because it's by definition a + // net-new route. If it has been loaded then bikeshed_load will have taken + // shouldRevalidate into consideration. + // + // There is a small edge case that _may_ result in a server loader running + // _somewhat_ unintended, but I'm pretty sure it's unavoidable: + // - Assume we have 2 routes, parent and child + // - Both have clientLoaders and both need to be revalidated + // - If neither calls `serverLoader`, we won't make the single fetch call + // - We delay the single fetch call until the **first** one calls `serverLoader` + // - However, we cannot wait around to know if the other one calls + // `serverLoader`, so we include both of them in the `X-Remix-Routes` + // header + // - This means it's technically possible that the second route never calls + // `serverLoader` and we never read the response of that route from the + // single fetch call, and thus executing that loader on the server was + // unnecessary. + let matchedIds = genRouteIds(matches.map((m) => m.route.id)); + let loadIds = genRouteIds( + matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) + ); + let headers = + matchedIds !== loadIds ? { "X-Remix-Routes": loadIds } : undefined; + + let res = await fetch(url, { headers }); + invariant( + res.body != null && + res.headers.get("Content-Type")?.includes("text/x-turbo"), + "Expected a text/x-turbo response" + ); + let decoded = await decode(res.body!); + return decoded.value as SingleFetchResults; + }; + + let singleFetch = async (routeId: string) => { if (!singleFetchPromise) { - singleFetchPromise = makeSingleFetchCall( - request, - isRevalidation ? revalidatingRoutes : undefined - ) as Promise; - // TODO: Pass this in from dataStrategy - // Maybe we can even just throw revalidationRequired or something on - // `DataStrategyMatch` and avoid all this await tick() stuff... - isRevalidation = false; + singleFetchPromise = makeSingleFetchCall(); } let results = await singleFetchPromise; if (results[routeId] !== undefined) { @@ -540,32 +532,28 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { return Promise.all( matches.map((m) => m.bikeshed_loadRoute(() => { - console.log("Inside loadRoute callback for route ", m.route.id); - revalidatingRoutes.add(m.route.id); let route = window.__remixManifest.routes[m.route.id]; let routeModule = window.__remixRouteModules[m.route.id]; - invariant( - routeModule, - "Expected a defined routeModule after bikeshed_loadRoute" - ); + invariant(routeModule, "Expected a routeModule in bikeshed_loadRoute"); if (routeModule.clientLoader) { return routeModule.clientLoader({ request, params: m.params, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint - serverLoader: () => - sharedSingleFetch(m.route.id) as Promise>, + serverLoader() { + preventInvalidServerHandlerCall( + "loader", + route, + window.__remixContext.isSpaMode + ); + return singleFetch(m.route.id) as Promise>; + }, }); } else if (route.hasLoader) { - return sharedSingleFetch(m.route.id); + return singleFetch(m.route.id); } else { - // TODO: We seem to get here for routes without a loader - - // they should get short circuited! - - // If we make it into the `bikeshed_loadRoute` callback we ought to - // have a handler to call so this shouldn't happen but I think some - // HMR/HDR scenarios might hit this flow? + // Remix routes without a server loader still have a "loader" on the + // client to preload styles, so just return nothing here. return Promise.resolve(null); } }) @@ -573,8 +561,8 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { ); } -function stripIndexParam(request: Request) { - let url = new URL(request.url); +function stripIndexParam(reqUrl: string) { + let url = new URL(reqUrl); let indexValues = url.searchParams.getAll("index"); url.searchParams.delete("index"); let indexValuesToKeep = []; @@ -587,5 +575,36 @@ function stripIndexParam(request: Request) { url.searchParams.append("index", toKeep); } - return new Request(url.href); + return url.href; +} + +function singleFetchUrl(reqUrl: string) { + let url = new URL(reqUrl); + url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; + return url; +} + +function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { + if ("error" in result) { + throw result.error; + } else if ("redirect" in result) { + let headers: Record = {}; + if (result.revalidate) { + headers["X-Remix-Revalidate"] = "yes"; + } + if (result.reload) { + headers["X-Remix-Reload-Document"] = "yes"; + } + return redirect(result.redirect, { status: result.status, headers }); + } else if ("data" in result) { + return result.data; + } else { + throw new Error(`No action response found for routeId "${routeId}"`); + } +} + +function genRouteIds(arr: string[]) { + return arr + .filter((id) => window.__remixManifest.routes[id].hasLoader) + .join(","); } diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index b089789fad6..b90efbc744a 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -196,7 +196,7 @@ export function createClientRoutesWithHMRRevalidationOptOut( ); } -function preventInvalidServerHandlerCall( +export function preventInvalidServerHandlerCall( type: "action" | "loader", route: Omit, isSpaMode: boolean diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 733d64b17a1..162205f5d0d 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -330,6 +330,7 @@ async function handleSingleFetchRequest( ) : await singleFetchLoaders( handlerUrl, + request.headers.get("X-Remix-Routes"), staticHandler, matches, loadContext, @@ -394,6 +395,7 @@ async function singleFetchAction( async function singleFetchLoaders( handlerUrl: URL, + routesToLoad: string | null, staticHandler: StaticHandler, matches: RouteMatch[] | null, loadContext: AppLoadContext, @@ -404,8 +406,11 @@ async function singleFetchLoaders( let context: StaticHandlerContext; try { let handlerRequest = new Request(handlerUrl); + let loadRouteIds = routesToLoad ? routesToLoad.split(",") : undefined; + let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, + loadRouteIds, }); if (isResponse(result)) { // We don't really know which loader this came from, so just stick it at @@ -462,19 +467,25 @@ async function singleFetchLoaders( } } - return [ - context.matches.reduce( - (acc, match) => - Object.assign(acc, { - [match.route.id]: - context.errors?.[match.route.id] !== undefined - ? { error: context.errors[match.route.id] } - : { data: context.loaderData[match.route.id] }, - }), - {} - ), - getDocumentHeaders(build, context), - ]; + // Aggregate results based on the matches we intended to load since we get + // `null` values back in `context.loaderData` for routes we didn't load + let results: SingleFetchResults = {}; + let loadedMatches = routesToLoad + ? context.matches.filter( + (m) => m.route.loader && routesToLoad.split(",").includes(m.route.id) + ) + : context.matches; + loadedMatches.forEach((m) => { + let data = context.loaderData?.[m.route.id]; + let error = context.errors?.[m.route.id]; + if (error !== undefined) { + results[m.route.id] = { error }; + } else if (data !== undefined) { + results[m.route.id] = { data }; + } + }); + + return [results, getDocumentHeaders(build, context)]; } async function handleDocumentRequest( From ce9b27df7d518050be1e52b861e41e663740c7d0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 13 Feb 2024 14:52:30 -0500 Subject: [PATCH 11/57] Fix action bodies --- packages/remix-react/browser.tsx | 15 +++++---- packages/remix-react/data.ts | 57 ++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 412155da08a..8108b96c681 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -27,11 +27,14 @@ import { createClientRoutes, createClientRoutesWithHMRRevalidationOptOut, shouldHydrateRouteLoader, - // TODO: Eventually we should move the single fetch stuff to data.ts and - // stop exporting these + // TODO: Eventually we should move the single fetch stuff to routes.ts or + // data.ts and stop exporting these noActionDefinedError, preventInvalidServerHandlerCall, } from "./routes"; +// TODO: Eventually we should move the single fetch stuff to routes.ts or +// data.ts and stop exporting these +import { createRequestInit } from "./data"; /* eslint-disable prefer-let/prefer-let */ declare global { @@ -388,8 +391,6 @@ async function singleFetchDataStrategy({ request, matches, }: DataStrategyFunctionArgs) { - // TODO: Do styles load twice on actions? - // Prefetch styles for matched routes that exist in the routeModulesCache // (critical modules and navigating back to pages previously loaded via // route.lazy). Initial execution of route.lazy (when the module is not in @@ -412,6 +413,7 @@ async function singleFetchDataStrategy({ let [routeData] = await Promise.all([dataPromise, stylesPromise]); return routeData; + // TODO: Do styles load twice on actions? // TODO: Critical route modules for single fetch // TODO: Don't revalidate on action 4xx/5xx responses with status codes // (return or throw) @@ -425,9 +427,8 @@ async function singleFetchDataStrategy({ function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { let singleFetch = async (routeId: string) => { - let res = await fetch(singleFetchUrl(request.url), { - method: request.method, - }); + let init = await createRequestInit(request); + let res = await fetch(singleFetchUrl(request.url), init); invariant( res.headers.get("Content-Type")?.includes("text/x-turbo"), "Expected a text/x-turbo response" diff --git a/packages/remix-react/data.ts b/packages/remix-react/data.ts index d6e80f42bf4..644248bd300 100644 --- a/packages/remix-react/data.ts +++ b/packages/remix-react/data.ts @@ -73,37 +73,13 @@ export async function fetchData( let url = new URL(request.url); url.searchParams.set("_data", routeId); - let init: RequestInit = { signal: request.signal }; - - if (request.method !== "GET") { - init.method = request.method; - - let contentType = request.headers.get("Content-Type"); - - // Check between word boundaries instead of startsWith() due to the last - // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type - if (contentType && /\bapplication\/json\b/.test(contentType)) { - init.headers = { "Content-Type": contentType }; - init.body = JSON.stringify(await request.json()); - } else if (contentType && /\btext\/plain\b/.test(contentType)) { - init.headers = { "Content-Type": contentType }; - init.body = await request.text(); - } else if ( - contentType && - /\bapplication\/x-www-form-urlencoded\b/.test(contentType) - ) { - init.body = new URLSearchParams(await request.text()); - } else { - init.body = await request.formData(); - } - } - if (retry > 0) { // Retry up to 3 times waiting 50, 250, 1250 ms // between retries for a total of 1550 ms before giving up. await new Promise((resolve) => setTimeout(resolve, 5 ** retry * 10)); } + let init = await createRequestInit(request); let revalidation = window.__remixRevalidation; let response = await fetch(url.href, init).catch((error) => { if ( @@ -134,6 +110,37 @@ export async function fetchData( return response; } +export async function createRequestInit( + request: Request +): Promise { + let init: RequestInit = { signal: request.signal }; + + if (request.method !== "GET") { + init.method = request.method; + + let contentType = request.headers.get("Content-Type"); + + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + if (contentType && /\bapplication\/json\b/.test(contentType)) { + init.headers = { "Content-Type": contentType }; + init.body = JSON.stringify(await request.json()); + } else if (contentType && /\btext\/plain\b/.test(contentType)) { + init.headers = { "Content-Type": contentType }; + init.body = await request.text(); + } else if ( + contentType && + /\bapplication\/x-www-form-urlencoded\b/.test(contentType) + ) { + init.body = new URLSearchParams(await request.text()); + } else { + init.body = await request.formData(); + } + } + + return init; +} + const DEFERRED_VALUE_PLACEHOLDER_PREFIX = "__deferred_promise:"; export async function parseDeferredReadableStream( stream: ReadableStream From a4631bd9415d4937e4c9a0babd87fdd10bf83443 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 14 Feb 2024 11:23:18 -0500 Subject: [PATCH 12/57] Bump RR experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 +- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +- yarn.lock | 136 +++++++++------------ 5 files changed, 67 insertions(+), 83 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index d8ebcad7f6a..0d44b6aa4cb 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-a0888892", + "@remix-run/router": "0.0.0-experimental-acfea932", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 918ad3e0644..a0da48ece9b 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-a0888892", + "@remix-run/router": "0.0.0-experimental-acfea932", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-a0888892", - "react-router-dom": "0.0.0-experimental-a0888892", + "react-router": "0.0.0-experimental-acfea932", + "react-router-dom": "0.0.0-experimental-acfea932", "turbo-stream": "^1.2.0" }, "devDependencies": { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 538ead047ca..c2066dd9003 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-a0888892", + "@remix-run/router": "0.0.0-experimental-acfea932", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index eb277e2e021..6beb25a5147 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-a0888892", - "react-router-dom": "0.0.0-experimental-a0888892" + "@remix-run/router": "0.0.0-experimental-acfea932", + "react-router-dom": "0.0.0-experimental-acfea932" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 7cd47e371d0..45c904adee4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-a0888892": - version "0.0.0-experimental-a0888892" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-a0888892.tgz#8c7bdbb05f35c839bea6cccf9e38e513167f8a85" - integrity sha512-sq4MivCFFjsHuSjwK5TCBqZzEgKTGNtuG6ykM+7InjtA+rEIt+tpnulai7HKbe9+fS4rDMnL5riWR+SOzCwMkg== +"@remix-run/router@0.0.0-experimental-acfea932": + version "0.0.0-experimental-acfea932" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-acfea932.tgz#f312602f20c47f5491732cf783d7192cc599b6b6" + integrity sha512-V/PbQ9+wQuorjzlslgkjOSVnVmoijXmizUPfQi+h3DTvZ6xRdCThse7lcJ9YQXsPgxUHzW4Ra4K3r26RbKYFgw== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -3578,25 +3578,17 @@ accepts@^1.3.7, accepts@~1.3.5, accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" -acorn-globals@^7.0.0: - version "7.0.1" - resolved "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" - integrity sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q== - dependencies: - acorn "^8.1.0" - acorn-walk "^8.0.2" - acorn-jsx@^5.0.0, acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.0.2, acorn-walk@^8.2.0: +acorn-walk@^8.2.0: version "8.3.2" resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^8.0.0, acorn@^8.1.0, acorn@^8.8.0, acorn@^8.8.1: +acorn@^8.0.0, acorn@^8.8.0, acorn@^8.8.1: version "8.11.3" resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -5034,22 +5026,12 @@ cssesc@^3.0.0: resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -cssom@^0.5.0: - version "0.5.0" - resolved "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" - integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== - -cssom@~0.3.6: - version "0.3.8" - resolved "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" - integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== - -cssstyle@^2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" - integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== +cssstyle@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a" + integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg== dependencies: - cssom "~0.3.6" + rrweb-cssom "^0.6.0" csstype@^3.0.2, csstype@^3.0.7: version "3.1.1" @@ -5156,14 +5138,14 @@ data-uri-to-buffer@^5.0.1: resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-5.0.1.tgz#db89a9e279c2ffe74f50637a59a32fb23b3e4d7c" integrity sha512-a9l6T1qqDogvvnw0nKlfZzqsyikEBZBClF39V3TFoKhDtGBqHu2HkuomJc02j5zft8zrUaXEuoicLeW54RkzPg== -data-urls@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" - integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== +data-urls@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" + integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g== dependencies: abab "^2.0.6" whatwg-mimetype "^3.0.0" - whatwg-url "^11.0.0" + whatwg-url "^12.0.0" dataloader@^1.4.0: version "1.4.0" @@ -5214,7 +5196,7 @@ decamelize@^1.1.0, decamelize@^1.2.0: resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -decimal.js@^10.4.2: +decimal.js@^10.4.3: version "10.4.3" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== @@ -5816,7 +5798,7 @@ escape-string-regexp@^5.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -escodegen@^2.0.0, escodegen@^2.1.0: +escodegen@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== @@ -8369,27 +8351,24 @@ jsbn@~0.1.0: resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= -jsdom@^20.0.0: - version "20.0.3" - resolved "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db" - integrity sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ== +jsdom@^20.0.0, jsdom@^22.0.0: + version "22.1.0" + resolved "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz#0fca6d1a37fbeb7f4aac93d1090d782c56b611c8" + integrity sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw== dependencies: abab "^2.0.6" - acorn "^8.8.1" - acorn-globals "^7.0.0" - cssom "^0.5.0" - cssstyle "^2.3.0" - data-urls "^3.0.2" - decimal.js "^10.4.2" + cssstyle "^3.0.0" + data-urls "^4.0.0" + decimal.js "^10.4.3" domexception "^4.0.0" - escodegen "^2.0.0" form-data "^4.0.0" html-encoding-sniffer "^3.0.0" http-proxy-agent "^5.0.0" https-proxy-agent "^5.0.1" is-potential-custom-element-name "^1.0.1" - nwsapi "^2.2.2" - parse5 "^7.1.1" + nwsapi "^2.2.4" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" saxes "^6.0.0" symbol-tree "^3.2.4" tough-cookie "^4.1.2" @@ -8397,8 +8376,8 @@ jsdom@^20.0.0: webidl-conversions "^7.0.0" whatwg-encoding "^2.0.0" whatwg-mimetype "^3.0.0" - whatwg-url "^11.0.0" - ws "^8.11.0" + whatwg-url "^12.0.1" + ws "^8.13.0" xml-name-validator "^4.0.0" jsesc@3.0.2: @@ -10331,7 +10310,7 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" -nwsapi@^2.2.2: +nwsapi@^2.2.4: version "2.2.7" resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== @@ -10676,7 +10655,7 @@ parse5-htmlparser2-tree-adapter@^7.0.0: domhandler "^5.0.2" parse5 "^7.0.0" -parse5@^7.0.0, parse5@^7.1.1: +parse5@^7.0.0, parse5@^7.1.2: version "7.1.2" resolved "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== @@ -11210,7 +11189,7 @@ pumpify@^1.3.3: inherits "^2.0.3" pump "^2.0.0" -punycode@^2.1.0, punycode@^2.1.1: +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -11321,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-a0888892: - version "0.0.0-experimental-a0888892" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-a0888892.tgz#34108386ec62fde554444974a61123d1189cc86f" - integrity sha512-EkMqTRzw+JA5ic3+kUztrFgux99XocPt3vtA+QzqOgfZL71UuUYbslfEKraXOyhFZIIYsFcHAfUA7LV3jCqTHg== +react-router-dom@0.0.0-experimental-acfea932: + version "0.0.0-experimental-acfea932" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-acfea932.tgz#953558a613c3cae83d39df2d3805d1cd7177c8f5" + integrity sha512-sNKcSI1qSqT858xIc2gkblBdpkr/TtZ/38w0tN/CxB6oDanmYCerfZuMKgs6da57hojn52V0Y6pjpRwbD64Kyg== dependencies: - "@remix-run/router" "0.0.0-experimental-a0888892" - react-router "0.0.0-experimental-a0888892" + "@remix-run/router" "0.0.0-experimental-acfea932" + react-router "0.0.0-experimental-acfea932" -react-router@0.0.0-experimental-a0888892: - version "0.0.0-experimental-a0888892" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-a0888892.tgz#f1f62988c9d75c68dd44b6f3dc91131b755e7e3b" - integrity sha512-XUTRKQhuHShOw+V6Nm05Cdv3FzJP6e3lOPTd4Cvdj+TyL6epCgnDr6TdH8HoGG4Swq/6LaiNPv8DtSiB++XzZg== +react-router@0.0.0-experimental-acfea932: + version "0.0.0-experimental-acfea932" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-acfea932.tgz#cff8d7638e7aa6357f07ef95ab29d20d9b659e1a" + integrity sha512-4N24eq812cR/h8LOHVvZknSp879NNyz8lcQtk3YC8/+Nul/tR0yiAqp3zdxXsnNAfZUG/bashCD0JZHNVLXaRg== dependencies: - "@remix-run/router" "0.0.0-experimental-a0888892" + "@remix-run/router" "0.0.0-experimental-acfea932" react@^18.2.0: version "18.2.0" @@ -11822,6 +11801,11 @@ rollup@^4.2.0: "@rollup/rollup-win32-x64-msvc" "4.4.1" fsevents "~2.3.2" +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + run-async@^2.4.0: version "2.4.1" resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz" @@ -12850,12 +12834,12 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" -tr46@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" - integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== dependencies: - punycode "^2.1.1" + punycode "^2.3.0" tr46@~0.0.3: version "0.0.3" @@ -13588,12 +13572,12 @@ whatwg-mimetype@^3.0.0: resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== -whatwg-url@^11.0.0: - version "11.0.0" - resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" - integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== +whatwg-url@^12.0.0, whatwg-url@^12.0.1: + version "12.0.1" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c" + integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ== dependencies: - tr46 "^3.0.0" + tr46 "^4.1.1" webidl-conversions "^7.0.0" whatwg-url@^5.0.0: @@ -13771,7 +13755,7 @@ ws@^7.4.5: resolved "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz" integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A== -ws@^8.11.0: +ws@^8.11.0, ws@^8.13.0: version "8.16.0" resolved "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== From dd0203beafe55ad8aafc418ba1d81dbe0607f088 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 14 Feb 2024 11:47:17 -0500 Subject: [PATCH 13/57] Move single fetch implementation out of browser.tsx --- packages/remix-react/browser.tsx | 256 +-------------------------- packages/remix-react/single-fetch.ts | 244 +++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 253 deletions(-) create mode 100644 packages/remix-react/single-fetch.ts diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 8108b96c681..cd1bd0f02f2 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,40 +1,21 @@ -import type { - HydrationState, - Router, - DataStrategyMatch, -} from "@remix-run/router"; -import type { SerializeFrom } from "@remix-run/server-runtime"; -import { - createBrowserHistory, - createRouter, - redirect, -} from "@remix-run/router"; +import type { HydrationState, Router } from "@remix-run/router"; +import { createBrowserHistory, createRouter } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; -import type { DataStrategyFunctionArgs } from "react-router-dom"; import { matchRoutes, RouterProvider } from "react-router-dom"; -import { decode } from "turbo-stream"; import { RemixContext } from "./components"; import type { EntryContext, FutureConfig } from "./entry"; import { RemixErrorBoundary } from "./errorBoundaries"; import { deserializeErrors } from "./errors"; -import invariant from "./invariant"; -import { prefetchStyleLinks } from "./links"; import type { RouteModules } from "./routeModules"; import { createClientRoutes, createClientRoutesWithHMRRevalidationOptOut, shouldHydrateRouteLoader, - // TODO: Eventually we should move the single fetch stuff to routes.ts or - // data.ts and stop exporting these - noActionDefinedError, - preventInvalidServerHandlerCall, } from "./routes"; -// TODO: Eventually we should move the single fetch stuff to routes.ts or -// data.ts and stop exporting these -import { createRequestInit } from "./data"; +import { singleFetchDataStrategy } from "./single-fetch"; /* eslint-disable prefer-let/prefer-let */ declare global { @@ -378,234 +359,3 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { ); } - -type SingleFetchResult = - | { data: unknown } - | { error: unknown } - | { redirect: string; status: number; revalidate: boolean; reload: boolean }; -type SingleFetchResults = { - [key: string]: SingleFetchResult; -}; - -async function singleFetchDataStrategy({ - request, - matches, -}: DataStrategyFunctionArgs) { - // Prefetch styles for matched routes that exist in the routeModulesCache - // (critical modules and navigating back to pages previously loaded via - // route.lazy). Initial execution of route.lazy (when the module is not in - // the cache) will handle prefetching style links via loadRouteModuleWithBlockingLinks. - let stylesPromise = Promise.all( - matches.map((m) => { - let route = window.__remixManifest.routes[m.route.id]; - let cachedModule = window.__remixRouteModules[m.route.id]; - return cachedModule - ? prefetchStyleLinks(route, cachedModule) - : Promise.resolve(); - }) - ); - - let dataPromise = - request.method === "GET" - ? singleFetchLoaders(request, matches) - : singleFetchAction(request, matches); - - let [routeData] = await Promise.all([dataPromise, stylesPromise]); - return routeData; - - // TODO: Do styles load twice on actions? - // TODO: Critical route modules for single fetch - // TODO: Don't revalidate on action 4xx/5xx responses with status codes - // (return or throw) - // TODO: Fix issue with auto-revalidating routes on HMR - // - load / - // - navigate to /parent/child - // - trigger HMR - // - back button to / - // - throws a "you returned undefined from a loader" error -} - -function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { - let singleFetch = async (routeId: string) => { - let init = await createRequestInit(request); - let res = await fetch(singleFetchUrl(request.url), init); - invariant( - res.headers.get("Content-Type")?.includes("text/x-turbo"), - "Expected a text/x-turbo response" - ); - let decoded = await decode(res.body!); - let result = decoded.value as SingleFetchResult; - return unwrapSingleFetchResult(result, routeId); - }; - - return Promise.all( - matches.map((m) => - m.bikeshed_loadRoute(() => { - let route = window.__remixManifest.routes[m.route.id]; - let routeModule = window.__remixRouteModules[m.route.id]; - invariant( - routeModule, - "Expected a defined routeModule after bikeshed_loadRoute" - ); - - if (routeModule.clientAction) { - return routeModule.clientAction({ - request, - params: m.params, - serverAction() { - preventInvalidServerHandlerCall( - "action", - route, - window.__remixContext.isSpaMode - ); - return singleFetch(m.route.id) as Promise>; - }, - }); - } else if (route.hasAction) { - return singleFetch(m.route.id); - } else { - throw noActionDefinedError("action", m.route.id); - } - }) - ) - ); -} - -function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { - // Create a singular promise for all routes to latch onto for single fetch. - // This way we can kick off `clientLoaders` and ensure: - // 1. we only call the server if at least one of them calls `serverLoader` - // 2. if multiple call` serverLoader` only one fetch call is made - let singleFetchPromise: Promise; - - let makeSingleFetchCall = async () => { - // Single fetch doesn't need/want naked index queries on action - // revalidation requests - let url = singleFetchUrl(stripIndexParam(request.url)); - - // Determine which routes we want to load so we can send an X-Remix-Routes header - // for fine-grained revalidation if necessary. If a route has not yet been loaded - // via `route.lazy` then we know we want to load it because it's by definition a - // net-new route. If it has been loaded then bikeshed_load will have taken - // shouldRevalidate into consideration. - // - // There is a small edge case that _may_ result in a server loader running - // _somewhat_ unintended, but I'm pretty sure it's unavoidable: - // - Assume we have 2 routes, parent and child - // - Both have clientLoaders and both need to be revalidated - // - If neither calls `serverLoader`, we won't make the single fetch call - // - We delay the single fetch call until the **first** one calls `serverLoader` - // - However, we cannot wait around to know if the other one calls - // `serverLoader`, so we include both of them in the `X-Remix-Routes` - // header - // - This means it's technically possible that the second route never calls - // `serverLoader` and we never read the response of that route from the - // single fetch call, and thus executing that loader on the server was - // unnecessary. - let matchedIds = genRouteIds(matches.map((m) => m.route.id)); - let loadIds = genRouteIds( - matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) - ); - let headers = - matchedIds !== loadIds ? { "X-Remix-Routes": loadIds } : undefined; - - let res = await fetch(url, { headers }); - invariant( - res.body != null && - res.headers.get("Content-Type")?.includes("text/x-turbo"), - "Expected a text/x-turbo response" - ); - let decoded = await decode(res.body!); - return decoded.value as SingleFetchResults; - }; - - let singleFetch = async (routeId: string) => { - if (!singleFetchPromise) { - singleFetchPromise = makeSingleFetchCall(); - } - let results = await singleFetchPromise; - if (results[routeId] !== undefined) { - return unwrapSingleFetchResult(results[routeId], routeId); - } - return null; - }; - - return Promise.all( - matches.map((m) => - m.bikeshed_loadRoute(() => { - let route = window.__remixManifest.routes[m.route.id]; - let routeModule = window.__remixRouteModules[m.route.id]; - invariant(routeModule, "Expected a routeModule in bikeshed_loadRoute"); - - if (routeModule.clientLoader) { - return routeModule.clientLoader({ - request, - params: m.params, - serverLoader() { - preventInvalidServerHandlerCall( - "loader", - route, - window.__remixContext.isSpaMode - ); - return singleFetch(m.route.id) as Promise>; - }, - }); - } else if (route.hasLoader) { - return singleFetch(m.route.id); - } else { - // Remix routes without a server loader still have a "loader" on the - // client to preload styles, so just return nothing here. - return Promise.resolve(null); - } - }) - ) - ); -} - -function stripIndexParam(reqUrl: string) { - let url = new URL(reqUrl); - let indexValues = url.searchParams.getAll("index"); - url.searchParams.delete("index"); - let indexValuesToKeep = []; - for (let indexValue of indexValues) { - if (indexValue) { - indexValuesToKeep.push(indexValue); - } - } - for (let toKeep of indexValuesToKeep) { - url.searchParams.append("index", toKeep); - } - - return url.href; -} - -function singleFetchUrl(reqUrl: string) { - let url = new URL(reqUrl); - url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; - return url; -} - -function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { - if ("error" in result) { - throw result.error; - } else if ("redirect" in result) { - let headers: Record = {}; - if (result.revalidate) { - headers["X-Remix-Revalidate"] = "yes"; - } - if (result.reload) { - headers["X-Remix-Reload-Document"] = "yes"; - } - return redirect(result.redirect, { status: result.status, headers }); - } else if ("data" in result) { - return result.data; - } else { - throw new Error(`No action response found for routeId "${routeId}"`); - } -} - -function genRouteIds(arr: string[]) { - return arr - .filter((id) => window.__remixManifest.routes[id].hasLoader) - .join(","); -} diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts new file mode 100644 index 00000000000..a2a771754ba --- /dev/null +++ b/packages/remix-react/single-fetch.ts @@ -0,0 +1,244 @@ +import type { DataStrategyMatch } from "@remix-run/router"; +import type { SerializeFrom } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/router"; +import type { DataStrategyFunctionArgs } from "react-router-dom"; +import { decode } from "turbo-stream"; + +import { createRequestInit } from "./data"; +import invariant from "./invariant"; +import { prefetchStyleLinks } from "./links"; +import { + noActionDefinedError, + preventInvalidServerHandlerCall, +} from "./routes"; + +type SingleFetchResult = + | { data: unknown } + | { error: unknown } + | { redirect: string; status: number; revalidate: boolean; reload: boolean }; +type SingleFetchResults = { + [key: string]: SingleFetchResult; +}; + +export async function singleFetchDataStrategy({ + request, + matches, +}: DataStrategyFunctionArgs) { + // Prefetch styles for matched routes that exist in the routeModulesCache + // (critical modules and navigating back to pages previously loaded via + // route.lazy). Initial execution of route.lazy (when the module is not in + // the cache) will handle prefetching style links via loadRouteModuleWithBlockingLinks. + let stylesPromise = Promise.all( + matches.map((m) => { + let route = window.__remixManifest.routes[m.route.id]; + let cachedModule = window.__remixRouteModules[m.route.id]; + return cachedModule + ? prefetchStyleLinks(route, cachedModule) + : Promise.resolve(); + }) + ); + + let dataPromise = + request.method === "GET" + ? singleFetchLoaders(request, matches) + : singleFetchAction(request, matches); + + let [routeData] = await Promise.all([dataPromise, stylesPromise]); + return routeData; + + // TODO: Do styles load twice on actions? + // TODO: Critical route modules for single fetch + // TODO: Don't revalidate on action 4xx/5xx responses with status codes + // (return or throw) + // TODO: Fix issue with auto-revalidating routes on HMR + // - load / + // - navigate to /parent/child + // - trigger HMR + // - back button to / + // - throws a "you returned undefined from a loader" error +} + +function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { + let singleFetch = async (routeId: string) => { + let init = await createRequestInit(request); + let res = await fetch(singleFetchUrl(request.url), init); + invariant( + res.headers.get("Content-Type")?.includes("text/x-turbo"), + "Expected a text/x-turbo response" + ); + let decoded = await decode(res.body!); + let result = decoded.value as SingleFetchResult; + return unwrapSingleFetchResult(result, routeId); + }; + + return Promise.all( + matches.map((m) => + m.bikeshed_loadRoute(() => { + let route = window.__remixManifest.routes[m.route.id]; + let routeModule = window.__remixRouteModules[m.route.id]; + invariant( + routeModule, + "Expected a defined routeModule after bikeshed_loadRoute" + ); + + if (routeModule.clientAction) { + return routeModule.clientAction({ + request, + params: m.params, + serverAction() { + preventInvalidServerHandlerCall( + "action", + route, + window.__remixContext.isSpaMode + ); + return singleFetch(m.route.id) as Promise>; + }, + }); + } else if (route.hasAction) { + return singleFetch(m.route.id); + } else { + throw noActionDefinedError("action", m.route.id); + } + }) + ) + ); +} + +function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { + // Create a singular promise for all routes to latch onto for single fetch. + // This way we can kick off `clientLoaders` and ensure: + // 1. we only call the server if at least one of them calls `serverLoader` + // 2. if multiple call` serverLoader` only one fetch call is made + let singleFetchPromise: Promise; + + let makeSingleFetchCall = async () => { + // Single fetch doesn't need/want naked index queries on action + // revalidation requests + let url = singleFetchUrl(stripIndexParam(request.url)); + + // Determine which routes we want to load so we can send an X-Remix-Routes header + // for fine-grained revalidation if necessary. If a route has not yet been loaded + // via `route.lazy` then we know we want to load it because it's by definition a + // net-new route. If it has been loaded then bikeshed_load will have taken + // shouldRevalidate into consideration. + // + // There is a small edge case that _may_ result in a server loader running + // _somewhat_ unintended, but I'm pretty sure it's unavoidable: + // - Assume we have 2 routes, parent and child + // - Both have clientLoaders and both need to be revalidated + // - If neither calls `serverLoader`, we won't make the single fetch call + // - We delay the single fetch call until the **first** one calls `serverLoader` + // - However, we cannot wait around to know if the other one calls + // `serverLoader`, so we include both of them in the `X-Remix-Routes` + // header + // - This means it's technically possible that the second route never calls + // `serverLoader` and we never read the response of that route from the + // single fetch call, and thus executing that loader on the server was + // unnecessary. + let matchedIds = genRouteIds(matches.map((m) => m.route.id)); + let loadIds = genRouteIds( + matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) + ); + let headers = + matchedIds !== loadIds ? { "X-Remix-Routes": loadIds } : undefined; + + let res = await fetch(url, { headers }); + invariant( + res.body != null && + res.headers.get("Content-Type")?.includes("text/x-turbo"), + "Expected a text/x-turbo response" + ); + let decoded = await decode(res.body!); + return decoded.value as SingleFetchResults; + }; + + let singleFetch = async (routeId: string) => { + if (!singleFetchPromise) { + singleFetchPromise = makeSingleFetchCall(); + } + let results = await singleFetchPromise; + if (results[routeId] !== undefined) { + return unwrapSingleFetchResult(results[routeId], routeId); + } + return null; + }; + + return Promise.all( + matches.map((m) => + m.bikeshed_loadRoute(() => { + let route = window.__remixManifest.routes[m.route.id]; + let routeModule = window.__remixRouteModules[m.route.id]; + invariant(routeModule, "Expected a routeModule in bikeshed_loadRoute"); + + if (routeModule.clientLoader) { + return routeModule.clientLoader({ + request, + params: m.params, + serverLoader() { + preventInvalidServerHandlerCall( + "loader", + route, + window.__remixContext.isSpaMode + ); + return singleFetch(m.route.id) as Promise>; + }, + }); + } else if (route.hasLoader) { + return singleFetch(m.route.id); + } else { + // Remix routes without a server loader still have a "loader" on the + // client to preload styles, so just return nothing here. + return Promise.resolve(null); + } + }) + ) + ); +} + +function stripIndexParam(reqUrl: string) { + let url = new URL(reqUrl); + let indexValues = url.searchParams.getAll("index"); + url.searchParams.delete("index"); + let indexValuesToKeep = []; + for (let indexValue of indexValues) { + if (indexValue) { + indexValuesToKeep.push(indexValue); + } + } + for (let toKeep of indexValuesToKeep) { + url.searchParams.append("index", toKeep); + } + + return url.href; +} + +function singleFetchUrl(reqUrl: string) { + let url = new URL(reqUrl); + url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; + return url; +} + +function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { + if ("error" in result) { + throw result.error; + } else if ("redirect" in result) { + let headers: Record = {}; + if (result.revalidate) { + headers["X-Remix-Revalidate"] = "yes"; + } + if (result.reload) { + headers["X-Remix-Reload-Document"] = "yes"; + } + return redirect(result.redirect, { status: result.status, headers }); + } else if ("data" in result) { + return result.data; + } else { + throw new Error(`No action response found for routeId "${routeId}"`); + } +} + +function genRouteIds(arr: string[]) { + return arr + .filter((id) => window.__remixManifest.routes[id].hasLoader) + .join(","); +} From 77ac53e3721f8166554c04f7324a8c06d82f2b4b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 14 Feb 2024 13:38:41 -0500 Subject: [PATCH 14/57] Fix css/loading parallelization issues by passing singleFetch through to existing loaders --- packages/remix-react/routes.tsx | 76 ++++++++++------- packages/remix-react/single-fetch.ts | 104 ++++-------------------- packages/remix-server-runtime/server.ts | 25 +++--- 3 files changed, 72 insertions(+), 133 deletions(-) diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index b90efbc744a..316da3bba04 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -306,7 +306,10 @@ export function createClientRoutes( needsRevalidation == null && (routeModule.clientLoader?.hydrate === true || !route.hasLoader); - dataRoute.loader = async ({ request, params }: LoaderFunctionArgs) => { + dataRoute.loader = async ( + { request, params }: LoaderFunctionArgs, + singleFetch?: unknown + ) => { try { let result = await prefetchStylesAndCallHandler(async () => { invariant( @@ -316,6 +319,9 @@ export function createClientRoutes( if (!routeModule.clientLoader) { if (isSpaMode) return null; // Call the server when no client loader exists + if (typeof singleFetch === "function") { + return singleFetch(); + } return fetchServerLoader(request); } @@ -334,6 +340,9 @@ export function createClientRoutes( } // Call the server loader for client-side navigations + if (typeof singleFetch === "function") { + return singleFetch(); + } let result = await fetchServerLoader(request); let unwrapped = await unwrapServerResponse(result); return unwrapped; @@ -355,7 +364,10 @@ export function createClientRoutes( isSpaMode ); - dataRoute.action = ({ request, params }: ActionFunctionArgs) => { + dataRoute.action = ( + { request, params }: ActionFunctionArgs, + singleFetch?: unknown + ) => { return prefetchStylesAndCallHandler(async () => { invariant( routeModule, @@ -365,6 +377,9 @@ export function createClientRoutes( if (isSpaMode) { throw noActionDefinedError("clientAction", route.id); } + if (typeof singleFetch === "function") { + return singleFetch(); + } return fetchServerAction(request); } @@ -380,48 +395,35 @@ export function createClientRoutes( }); }); }; - } else if (future.unstable_singleFetch) { - dataRoute.lazy = async () => { - let mod = await loadRouteModuleWithBlockingLinks( - route, - routeModulesCache - ); - - return { - // We just need booleans here when single fetch is enabled to get them - // into `matchesToLoad` - we'll handle the rest of it in `dataStrategy` - loader: route.hasLoader || route.hasClientLoader, - action: route.hasAction || route.hasClientAction, - hasErrorBoundary: mod.ErrorBoundary !== undefined, - shouldRevalidate: needsRevalidation - ? wrapShouldRevalidateForHdr( - route.id, - mod.shouldRevalidate, - needsRevalidation - ) - : mod.shouldRevalidate, - handle: mod.handle, - Component: mod.Component, - ErrorBoundary: mod.ErrorBoundary, - }; - }; } else { // If the lazy route does not have a client loader/action we want to call // the server loader/action in parallel with the module load so we add // loader/action as static props on the route if (!route.hasClientLoader) { - dataRoute.loader = ({ request }: LoaderFunctionArgs) => + dataRoute.loader = ( + { request }: LoaderFunctionArgs, + singleFetch?: unknown + ) => prefetchStylesAndCallHandler(() => { if (isSpaMode) return Promise.resolve(null); + if (typeof singleFetch === "function") { + return singleFetch(); + } return fetchServerLoader(request); }); } if (!route.hasClientAction) { - dataRoute.action = ({ request }: ActionFunctionArgs) => + dataRoute.action = ( + { request }: ActionFunctionArgs, + singleFetch?: unknown + ) => prefetchStylesAndCallHandler(() => { if (isSpaMode) { throw noActionDefinedError("clientAction", route.id); } + if (typeof singleFetch === "function") { + return singleFetch(); + } return fetchServerAction(request); }); } @@ -436,11 +438,17 @@ export function createClientRoutes( let lazyRoute: Partial = { ...mod }; if (mod.clientLoader) { let clientLoader = mod.clientLoader; - lazyRoute.loader = (args) => + lazyRoute.loader = ( + args: LoaderFunctionArgs, + singleFetch?: unknown + ) => clientLoader({ ...args, async serverLoader() { preventInvalidServerHandlerCall("loader", route, isSpaMode); + if (typeof singleFetch === "function") { + return singleFetch(); + } let response = await fetchServerLoader(args.request); let result = await unwrapServerResponse(response); return result; @@ -450,11 +458,17 @@ export function createClientRoutes( if (mod.clientAction) { let clientAction = mod.clientAction; - lazyRoute.action = (args) => + lazyRoute.action = ( + args: ActionFunctionArgs, + singleFetch?: unknown + ) => clientAction({ ...args, async serverAction() { preventInvalidServerHandlerCall("action", route, isSpaMode); + if (typeof singleFetch === "function") { + return singleFetch(); + } let response = await fetchServerAction(args.request); let result = await unwrapServerResponse(response); return result; diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index a2a771754ba..7e31020f0af 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -1,16 +1,10 @@ import type { DataStrategyMatch } from "@remix-run/router"; -import type { SerializeFrom } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/router"; import type { DataStrategyFunctionArgs } from "react-router-dom"; import { decode } from "turbo-stream"; import { createRequestInit } from "./data"; import invariant from "./invariant"; -import { prefetchStyleLinks } from "./links"; -import { - noActionDefinedError, - preventInvalidServerHandlerCall, -} from "./routes"; type SingleFetchResult = | { data: unknown } @@ -24,38 +18,12 @@ export async function singleFetchDataStrategy({ request, matches, }: DataStrategyFunctionArgs) { - // Prefetch styles for matched routes that exist in the routeModulesCache - // (critical modules and navigating back to pages previously loaded via - // route.lazy). Initial execution of route.lazy (when the module is not in - // the cache) will handle prefetching style links via loadRouteModuleWithBlockingLinks. - let stylesPromise = Promise.all( - matches.map((m) => { - let route = window.__remixManifest.routes[m.route.id]; - let cachedModule = window.__remixRouteModules[m.route.id]; - return cachedModule - ? prefetchStyleLinks(route, cachedModule) - : Promise.resolve(); - }) - ); - - let dataPromise = - request.method === "GET" - ? singleFetchLoaders(request, matches) - : singleFetchAction(request, matches); - - let [routeData] = await Promise.all([dataPromise, stylesPromise]); - return routeData; + return request.method === "GET" + ? singleFetchLoaders(request, matches) + : singleFetchAction(request, matches); - // TODO: Do styles load twice on actions? - // TODO: Critical route modules for single fetch // TODO: Don't revalidate on action 4xx/5xx responses with status codes // (return or throw) - // TODO: Fix issue with auto-revalidating routes on HMR - // - load / - // - navigate to /parent/child - // - trigger HMR - // - back button to / - // - throws a "you returned undefined from a loader" error } function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { @@ -73,33 +41,7 @@ function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { return Promise.all( matches.map((m) => - m.bikeshed_loadRoute(() => { - let route = window.__remixManifest.routes[m.route.id]; - let routeModule = window.__remixRouteModules[m.route.id]; - invariant( - routeModule, - "Expected a defined routeModule after bikeshed_loadRoute" - ); - - if (routeModule.clientAction) { - return routeModule.clientAction({ - request, - params: m.params, - serverAction() { - preventInvalidServerHandlerCall( - "action", - route, - window.__remixContext.isSpaMode - ); - return singleFetch(m.route.id) as Promise>; - }, - }); - } else if (route.hasAction) { - return singleFetch(m.route.id); - } else { - throw noActionDefinedError("action", m.route.id); - } - }) + m.bikeshed_loadRoute((handler) => handler(() => singleFetch(m.route.id))) ) ); } @@ -139,6 +81,9 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { let loadIds = genRouteIds( matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) ); + + // TODO: Should we only do this on revalidations? We don't know here whether this is a new + // route load or a revalidation but we could communicate that through to dataStrategy let headers = matchedIds !== loadIds ? { "X-Remix-Routes": loadIds } : undefined; @@ -163,35 +108,14 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { return null; }; + // Call the route loaders passing through the singleFetch function that will + // be called instead of making a server call return Promise.all( - matches.map((m) => - m.bikeshed_loadRoute(() => { - let route = window.__remixManifest.routes[m.route.id]; - let routeModule = window.__remixRouteModules[m.route.id]; - invariant(routeModule, "Expected a routeModule in bikeshed_loadRoute"); - - if (routeModule.clientLoader) { - return routeModule.clientLoader({ - request, - params: m.params, - serverLoader() { - preventInvalidServerHandlerCall( - "loader", - route, - window.__remixContext.isSpaMode - ); - return singleFetch(m.route.id) as Promise>; - }, - }); - } else if (route.hasLoader) { - return singleFetch(m.route.id); - } else { - // Remix routes without a server loader still have a "loader" on the - // client to preload styles, so just return nothing here. - return Promise.resolve(null); - } - }) - ) + matches.map(async (m) => { + return m.bikeshed_loadRoute((handler) => + handler(() => singleFetch(m.route.id)) + ); + }) ); } diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 162205f5d0d..3c77e19b93a 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -23,8 +23,9 @@ import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; import { getDocumentHeadersRR as getDocumentHeaders } from "./headers"; import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; -import { RouteMatch, matchServerRoutes } from "./routeMatching"; -import type { Route, ServerRoute } from "./routes"; +import type { RouteMatch } from "./routeMatching"; +import { matchServerRoutes } from "./routeMatching"; +import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createDeferredReadableStream, @@ -105,6 +106,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( let url = new URL(request.url); let matches = matchServerRoutes(routes, url.pathname, _build.basename); + let params = matches && matches.length > 0 ? matches[0].params : {}; let handleError = (error: unknown) => { if (mode === ServerMode.Development) { getDevServerHooks()?.processRequestError?.(error); @@ -112,7 +114,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( errorHandler(error, { context: loadContext, - params: matches && matches.length > 0 ? matches[0].params : {}, + params, request, }); }; @@ -134,7 +136,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( if (_build.entry.module.handleDataRequest) { response = await _build.entry.module.handleDataRequest(response, { context: loadContext, - params: matches?.find((m) => m.route.id == routeId)?.params || {}, + params, request, }); } @@ -164,14 +166,13 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( handleError ); - // TODO: - // if (_build.entry.module.handleDataRequest) { - // response = await _build.entry.module.handleDataRequest(response, { - // context: loadContext, - // params: matches?.find((m) => m.route.id == routeId)?.params || {}, - // request, - // }); - // } + if (_build.entry.module.handleDataRequest) { + response = await _build.entry.module.handleDataRequest(response, { + context: loadContext, + params, + request, + }); + } } else if ( matches && matches[matches.length - 1].route.module.default == null && From a0fffd74e7bc15c33d0c4119ebb44f7014230507 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 14 Feb 2024 17:55:14 -0500 Subject: [PATCH 15/57] Bump RR Experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 0d44b6aa4cb..8212dee4e8a 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-acfea932", + "@remix-run/router": "0.0.0-experimental-3ba3024e", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index a0da48ece9b..75702b68828 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-acfea932", + "@remix-run/router": "0.0.0-experimental-3ba3024e", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-acfea932", - "react-router-dom": "0.0.0-experimental-acfea932", + "react-router": "0.0.0-experimental-3ba3024e", + "react-router-dom": "0.0.0-experimental-3ba3024e", "turbo-stream": "^1.2.0" }, "devDependencies": { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index c2066dd9003..345677d2cb9 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-acfea932", + "@remix-run/router": "0.0.0-experimental-3ba3024e", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index 6beb25a5147..e6319571666 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-acfea932", - "react-router-dom": "0.0.0-experimental-acfea932" + "@remix-run/router": "0.0.0-experimental-3ba3024e", + "react-router-dom": "0.0.0-experimental-3ba3024e" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 45c904adee4..ca7fafa3af2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-acfea932": - version "0.0.0-experimental-acfea932" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-acfea932.tgz#f312602f20c47f5491732cf783d7192cc599b6b6" - integrity sha512-V/PbQ9+wQuorjzlslgkjOSVnVmoijXmizUPfQi+h3DTvZ6xRdCThse7lcJ9YQXsPgxUHzW4Ra4K3r26RbKYFgw== +"@remix-run/router@0.0.0-experimental-3ba3024e": + version "0.0.0-experimental-3ba3024e" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-3ba3024e.tgz#628732fed8aac3ecdcf0d00923f32ca9c4985b06" + integrity sha512-er5jSxH6ysjJRYqvMVvh3UUeiyvjcwImimDnwk4gIMldyDIAfftrWaBSUKMzLlFcp91sv5+jPjJ1lnNCpZbvTA== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-acfea932: - version "0.0.0-experimental-acfea932" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-acfea932.tgz#953558a613c3cae83d39df2d3805d1cd7177c8f5" - integrity sha512-sNKcSI1qSqT858xIc2gkblBdpkr/TtZ/38w0tN/CxB6oDanmYCerfZuMKgs6da57hojn52V0Y6pjpRwbD64Kyg== +react-router-dom@0.0.0-experimental-3ba3024e: + version "0.0.0-experimental-3ba3024e" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-3ba3024e.tgz#7553154b9e25aa0a2e6db33bbfaf0657242523cd" + integrity sha512-HAJ0trtIrPniaTZVUfEZ1yC8IbpTtrx5toMNb+ILFg7dgRVk2g4SnFjn5iiAv0Pez/eJj5xgHEASM0mvko2DUw== dependencies: - "@remix-run/router" "0.0.0-experimental-acfea932" - react-router "0.0.0-experimental-acfea932" + "@remix-run/router" "0.0.0-experimental-3ba3024e" + react-router "0.0.0-experimental-3ba3024e" -react-router@0.0.0-experimental-acfea932: - version "0.0.0-experimental-acfea932" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-acfea932.tgz#cff8d7638e7aa6357f07ef95ab29d20d9b659e1a" - integrity sha512-4N24eq812cR/h8LOHVvZknSp879NNyz8lcQtk3YC8/+Nul/tR0yiAqp3zdxXsnNAfZUG/bashCD0JZHNVLXaRg== +react-router@0.0.0-experimental-3ba3024e: + version "0.0.0-experimental-3ba3024e" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-3ba3024e.tgz#24c848f14cdb1751fa1545f421f0d655daa09f08" + integrity sha512-t8mtq8YFOJTHX5es1oQUFzHw59qautzDcYxiQ8CstpjR90UiXzyzzMc1LTGiayRMKHeQhvaKZrGEc31Cxom8Qw== dependencies: - "@remix-run/router" "0.0.0-experimental-acfea932" + "@remix-run/router" "0.0.0-experimental-3ba3024e" react@^18.2.0: version "18.2.0" From a2dc7a4dc4a1641d3fc55fb35a8f04392a3ced95 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 14 Feb 2024 18:03:29 -0500 Subject: [PATCH 16/57] Streamline routes code and fix a few e2e tests bugs --- packages/remix-react/components.tsx | 2 +- packages/remix-react/routes.tsx | 71 +++++++++++-------------- packages/remix-react/single-fetch.ts | 71 ++++++++++++++++--------- packages/remix-server-runtime/server.ts | 20 +++++-- 4 files changed, 91 insertions(+), 73 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index b93baee5bb7..a8252d28461 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -283,7 +283,7 @@ function getActiveMatches( } if (errors) { - let errorIdx = matches.findIndex((m) => errors[m.route.id]); + let errorIdx = matches.findIndex((m) => errors[m.route.id] !== undefined); return matches.slice(0, errorIdx + 1); } diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 316da3bba04..dfe5f265978 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -249,16 +249,34 @@ export function createClientRoutes( return (routesByParentId[parentId] || []).map((route) => { let routeModule = routeModulesCache[route.id]; - async function fetchServerLoader(request: Request) { + async function fetchServerLoader( + request: Request, + unwrap: boolean, + singleFetch: unknown + ) { if (!route.hasLoader) return null; - return fetchServerHandler(request, route); + if (typeof singleFetch === "function") { + let result = await singleFetch(); + return result; + } + let result = await fetchServerHandler(request, route); + return unwrap ? unwrapServerResponse(result) : result; } - async function fetchServerAction(request: Request) { + async function fetchServerAction( + request: Request, + unwrap: boolean, + singleFetch: unknown + ) { if (!route.hasAction) { throw noActionDefinedError("action", route.id); } - return fetchServerHandler(request, route); + if (typeof singleFetch === "function") { + let result = await singleFetch(); + return result; + } + let result = await fetchServerHandler(request, route); + return unwrap ? unwrapServerResponse(result) : result; } async function prefetchStylesAndCallHandler( @@ -319,10 +337,7 @@ export function createClientRoutes( if (!routeModule.clientLoader) { if (isSpaMode) return null; // Call the server when no client loader exists - if (typeof singleFetch === "function") { - return singleFetch(); - } - return fetchServerLoader(request); + return fetchServerLoader(request, false, singleFetch); } return routeModule.clientLoader({ @@ -340,12 +355,7 @@ export function createClientRoutes( } // Call the server loader for client-side navigations - if (typeof singleFetch === "function") { - return singleFetch(); - } - let result = await fetchServerLoader(request); - let unwrapped = await unwrapServerResponse(result); - return unwrapped; + return fetchServerLoader(request, true, singleFetch); }, }); }); @@ -377,10 +387,7 @@ export function createClientRoutes( if (isSpaMode) { throw noActionDefinedError("clientAction", route.id); } - if (typeof singleFetch === "function") { - return singleFetch(); - } - return fetchServerAction(request); + return fetchServerAction(request, false, singleFetch); } return routeModule.clientAction({ @@ -388,9 +395,7 @@ export function createClientRoutes( params, async serverAction() { preventInvalidServerHandlerCall("action", route, isSpaMode); - let result = await fetchServerAction(request); - let unwrapped = await unwrapServerResponse(result); - return unwrapped; + return fetchServerAction(request, true, singleFetch); }, }); }); @@ -406,10 +411,7 @@ export function createClientRoutes( ) => prefetchStylesAndCallHandler(() => { if (isSpaMode) return Promise.resolve(null); - if (typeof singleFetch === "function") { - return singleFetch(); - } - return fetchServerLoader(request); + return fetchServerLoader(request, false, singleFetch); }); } if (!route.hasClientAction) { @@ -421,10 +423,7 @@ export function createClientRoutes( if (isSpaMode) { throw noActionDefinedError("clientAction", route.id); } - if (typeof singleFetch === "function") { - return singleFetch(); - } - return fetchServerAction(request); + return fetchServerAction(request, false, singleFetch); }); } @@ -446,12 +445,7 @@ export function createClientRoutes( ...args, async serverLoader() { preventInvalidServerHandlerCall("loader", route, isSpaMode); - if (typeof singleFetch === "function") { - return singleFetch(); - } - let response = await fetchServerLoader(args.request); - let result = await unwrapServerResponse(response); - return result; + return fetchServerLoader(args.request, true, singleFetch); }, }); } @@ -466,12 +460,7 @@ export function createClientRoutes( ...args, async serverAction() { preventInvalidServerHandlerCall("action", route, isSpaMode); - if (typeof singleFetch === "function") { - return singleFetch(); - } - let response = await fetchServerAction(args.request); - let result = await unwrapServerResponse(response); - return result; + return fetchServerAction(args.request, true, singleFetch); }, }); } diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index 7e31020f0af..270b385f240 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -1,5 +1,8 @@ -import type { DataStrategyMatch } from "@remix-run/router"; -import { redirect } from "@remix-run/router"; +import type { DataStrategyMatch, ErrorResponse } from "@remix-run/router"; +import { + redirect, + UNSAFE_ErrorResponseImpl as ErrorResponseImpl, +} from "@remix-run/router"; import type { DataStrategyFunctionArgs } from "react-router-dom"; import { decode } from "turbo-stream"; @@ -28,15 +31,10 @@ export async function singleFetchDataStrategy({ function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { let singleFetch = async (routeId: string) => { + let url = singleFetchUrl(request.url); let init = await createRequestInit(request); - let res = await fetch(singleFetchUrl(request.url), init); - invariant( - res.headers.get("Content-Type")?.includes("text/x-turbo"), - "Expected a text/x-turbo response" - ); - let decoded = await decode(res.body!); - let result = decoded.value as SingleFetchResult; - return unwrapSingleFetchResult(result, routeId); + let result = await fetchAndDecode(url, init); + return unwrapSingleFetchResult(result as SingleFetchResult, routeId); }; return Promise.all( @@ -54,10 +52,6 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { let singleFetchPromise: Promise; let makeSingleFetchCall = async () => { - // Single fetch doesn't need/want naked index queries on action - // revalidation requests - let url = singleFetchUrl(stripIndexParam(request.url)); - // Determine which routes we want to load so we can send an X-Remix-Routes header // for fine-grained revalidation if necessary. If a route has not yet been loaded // via `route.lazy` then we know we want to load it because it's by definition a @@ -82,19 +76,20 @@ function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) ); - // TODO: Should we only do this on revalidations? We don't know here whether this is a new - // route load or a revalidation but we could communicate that through to dataStrategy - let headers = - matchedIds !== loadIds ? { "X-Remix-Routes": loadIds } : undefined; + // Single fetch doesn't need/want naked index queries on action + // revalidation requests + let url = singleFetchUrl(stripIndexParam(request.url)); - let res = await fetch(url, { headers }); - invariant( - res.body != null && - res.headers.get("Content-Type")?.includes("text/x-turbo"), - "Expected a text/x-turbo response" - ); - let decoded = await decode(res.body!); - return decoded.value as SingleFetchResults; + // TODO: Should we only do this on revalidations? We don't know here whether + // this is a new route load or a revalidation but we could communicate that + // through to dataStrategy + let init: RequestInit = { + ...(matchedIds !== loadIds + ? { headers: { "X-Remix-Routes": loadIds } } + : null), + }; + let result = await fetchAndDecode(url, init); + return result as SingleFetchResults; }; let singleFetch = async (routeId: string) => { @@ -142,6 +137,30 @@ function singleFetchUrl(reqUrl: string) { return url; } +async function fetchAndDecode(url: URL, init: RequestInit) { + let res = await fetch(url, init); + invariant( + res.headers.get("Content-Type")?.includes("text/x-turbo"), + "Expected a text/x-turbo response" + ); + let decoded = await decode(res.body!, [ + (type, value) => { + if (type === "ErrorResponse") { + let errorResponse = value as ErrorResponse; + return { + value: new ErrorResponseImpl( + errorResponse.status, + errorResponse.statusText, + errorResponse.data, + (errorResponse as any).internal === true + ), + }; + } + }, + ]); + return decoded.value; +} + function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { if ("error" in result) { throw result.error; diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 3c77e19b93a..265936cb421 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -348,7 +348,16 @@ async function handleSingleFetchRequest( // Note: Deferred data is already just Promises, so we don't have to mess // `activeDeferreds` or anything :) - return new Response(encode(result), { headers: resultHeaders }); + return new Response( + encode(result, [ + (value) => { + if (value instanceof ErrorResponseImpl) { + return ["ErrorResponse", { ...value }]; + } + }, + ]), + { headers: resultHeaders } + ); } async function singleFetchAction( @@ -388,8 +397,9 @@ async function singleFetchAction( ]; } return [{ data: await unwrapResponse(response) }, response.headers]; - } catch (error) { - handleError(error); + } catch (err) { + handleError(err); + let error = isResponse(err) ? await unwrapResponse(err) : err; return [{ error }, new Headers()]; } } @@ -458,9 +468,9 @@ async function singleFetchLoaders( } if ( build.assets.routes[routeId]?.hasLoader && - context.loaderData[routeId] === undefined + context.loaderData[routeId] === undefined && + mostRecentError ) { - invariant(mostRecentError, "Expected mostRecentError to be set"); context.errors[mostRecentError[0]] = undefined; context.errors[routeId] = mostRecentError[1]; mostRecentError = null; From 49b46bea16b8e3df4a40282ee00cbd51a6b4451e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Feb 2024 14:17:39 -0500 Subject: [PATCH 17/57] Bump turbo-stream --- packages/remix-react/package.json | 2 +- packages/remix-server-runtime/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 75702b68828..41bb1375d2d 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -20,7 +20,7 @@ "@remix-run/server-runtime": "2.6.0", "react-router": "0.0.0-experimental-3ba3024e", "react-router-dom": "0.0.0-experimental-3ba3024e", - "turbo-stream": "^1.2.0" + "turbo-stream": "^1.2.1" }, "devDependencies": { "@testing-library/jest-dom": "^5.17.0", diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 345677d2cb9..69ea176a363 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -22,7 +22,7 @@ "cookie": "^0.6.0", "set-cookie-parser": "^2.4.8", "source-map": "^0.7.3", - "turbo-stream": "^1.2.0" + "turbo-stream": "^1.2.1" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/yarn.lock b/yarn.lock index ca7fafa3af2..6883828da4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12937,10 +12937,10 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -turbo-stream@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/turbo-stream/-/turbo-stream-1.2.0.tgz#1388dd457d94970e11832c92475d5264d652049e" - integrity sha512-aunXYgJ3hcqutvmtZ/aZWpWsNZGFiMp+Yw29Z6w0jnH69wrCLzsAO6RR6PI6ivY9tq9PdwlyxHY2WBvlYm8jzA== +turbo-stream@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/turbo-stream/-/turbo-stream-1.2.1.tgz#13b0d9b077fb1606d7ec62b458a866c36ff201fb" + integrity sha512-8MTM4cWS98lL4Oo5E30opwdfvnGbsPFnErILO3ib71s8a9VLDvPh/seqoBxpCWrCpnEs9O8sILM+SUAemgIMOA== tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" From bbc0728affbbaa24a66270c16ba01716e5d1108f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Feb 2024 17:26:00 -0500 Subject: [PATCH 18/57] Switch from X-Remix-Routes header to _routes query param --- packages/remix-react/browser.tsx | 7 +- packages/remix-react/single-fetch.ts | 218 +++++++++++++----------- packages/remix-server-runtime/server.ts | 2 +- 3 files changed, 122 insertions(+), 105 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index cd1bd0f02f2..4ae8631a971 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -15,7 +15,7 @@ import { createClientRoutesWithHMRRevalidationOptOut, shouldHydrateRouteLoader, } from "./routes"; -import { singleFetchDataStrategy } from "./single-fetch"; +import { getSingleFetchDataStrategy } from "./single-fetch"; /* eslint-disable prefer-let/prefer-let */ declare global { @@ -276,7 +276,10 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { hydrationData, mapRouteProperties, unstable_dataStrategy: window.__remixContext.future.unstable_singleFetch - ? singleFetchDataStrategy + ? getSingleFetchDataStrategy( + window.__remixManifest, + window.__remixRouteModules + ) : undefined, }); diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index 270b385f240..08e7302eb65 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -1,13 +1,15 @@ import type { DataStrategyMatch, ErrorResponse } from "@remix-run/router"; import { - redirect, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, + redirect, } from "@remix-run/router"; import type { DataStrategyFunctionArgs } from "react-router-dom"; import { decode } from "turbo-stream"; import { createRequestInit } from "./data"; +import type { AssetsManifest } from "./entry"; import invariant from "./invariant"; +import type { RouteModules } from "./routeModules"; type SingleFetchResult = | { data: unknown } @@ -17,105 +19,71 @@ type SingleFetchResults = { [key: string]: SingleFetchResult; }; -export async function singleFetchDataStrategy({ - request, - matches, -}: DataStrategyFunctionArgs) { - return request.method === "GET" - ? singleFetchLoaders(request, matches) - : singleFetchAction(request, matches); - - // TODO: Don't revalidate on action 4xx/5xx responses with status codes - // (return or throw) -} - -function singleFetchAction(request: Request, matches: DataStrategyMatch[]) { - let singleFetch = async (routeId: string) => { - let url = singleFetchUrl(request.url); - let init = await createRequestInit(request); - let result = await fetchAndDecode(url, init); - return unwrapSingleFetchResult(result as SingleFetchResult, routeId); - }; - - return Promise.all( - matches.map((m) => - m.bikeshed_loadRoute((handler) => handler(() => singleFetch(m.route.id))) - ) - ); -} - -function singleFetchLoaders(request: Request, matches: DataStrategyMatch[]) { - // Create a singular promise for all routes to latch onto for single fetch. - // This way we can kick off `clientLoaders` and ensure: - // 1. we only call the server if at least one of them calls `serverLoader` - // 2. if multiple call` serverLoader` only one fetch call is made - let singleFetchPromise: Promise; +export function getSingleFetchDataStrategy( + manifest: AssetsManifest, + routeModules: RouteModules +) { + return async ({ request, matches }: DataStrategyFunctionArgs) => { + // This function is the way for a loader/action to "talk" to the server + let singleFetch: (routeId: string) => Promise; + if (request.method !== "GET") { + // Actions are simple since they're singular - just hit the server + singleFetch = async (routeId) => { + let url = singleFetchUrl(request.url); + let init = await createRequestInit(request); + let result = await fetchAndDecode(url, init); + return unwrapSingleFetchResult(result as SingleFetchResult, routeId); + }; + } else { + // Loaders are trickier since we only want to hit the server once, so we + // create a singular promise for all routes to latch onto. This way we can + // kick off any existing `clientLoaders` and ensure: + // 1. we only call the server if at least one of them calls `serverLoader` + // 2. if multiple call `serverLoader` only one fetch call is made + let singleFetchPromise: Promise; + + let makeSingleFetchCall = async () => { + // Single fetch doesn't need/want naked index queries on action + // revalidation requests + let url = singleFetchUrl( + addRevalidationParam( + manifest, + routeModules, + matches, + stripIndexParam(request.url) + ) + ); + + let result = await fetchAndDecode(url); + return result as SingleFetchResults; + }; + + singleFetch = async (routeId) => { + if (!singleFetchPromise) { + singleFetchPromise = makeSingleFetchCall(); + } + let results = await singleFetchPromise; + if (results[routeId] !== undefined) { + return unwrapSingleFetchResult(results[routeId], routeId); + } + return null; + }; + } - let makeSingleFetchCall = async () => { - // Determine which routes we want to load so we can send an X-Remix-Routes header - // for fine-grained revalidation if necessary. If a route has not yet been loaded - // via `route.lazy` then we know we want to load it because it's by definition a - // net-new route. If it has been loaded then bikeshed_load will have taken - // shouldRevalidate into consideration. - // - // There is a small edge case that _may_ result in a server loader running - // _somewhat_ unintended, but I'm pretty sure it's unavoidable: - // - Assume we have 2 routes, parent and child - // - Both have clientLoaders and both need to be revalidated - // - If neither calls `serverLoader`, we won't make the single fetch call - // - We delay the single fetch call until the **first** one calls `serverLoader` - // - However, we cannot wait around to know if the other one calls - // `serverLoader`, so we include both of them in the `X-Remix-Routes` - // header - // - This means it's technically possible that the second route never calls - // `serverLoader` and we never read the response of that route from the - // single fetch call, and thus executing that loader on the server was - // unnecessary. - let matchedIds = genRouteIds(matches.map((m) => m.route.id)); - let loadIds = genRouteIds( - matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) + // Call the route handlers passing through the `singleFetch` function that will + // be called instead of making a server call + return Promise.all( + matches.map(async (m) => { + return m.bikeshed_loadRoute((handler) => + handler(() => singleFetch(m.route.id)) + ); + }) ); - - // Single fetch doesn't need/want naked index queries on action - // revalidation requests - let url = singleFetchUrl(stripIndexParam(request.url)); - - // TODO: Should we only do this on revalidations? We don't know here whether - // this is a new route load or a revalidation but we could communicate that - // through to dataStrategy - let init: RequestInit = { - ...(matchedIds !== loadIds - ? { headers: { "X-Remix-Routes": loadIds } } - : null), - }; - let result = await fetchAndDecode(url, init); - return result as SingleFetchResults; }; - - let singleFetch = async (routeId: string) => { - if (!singleFetchPromise) { - singleFetchPromise = makeSingleFetchCall(); - } - let results = await singleFetchPromise; - if (results[routeId] !== undefined) { - return unwrapSingleFetchResult(results[routeId], routeId); - } - return null; - }; - - // Call the route loaders passing through the singleFetch function that will - // be called instead of making a server call - return Promise.all( - matches.map(async (m) => { - return m.bikeshed_loadRoute((handler) => - handler(() => singleFetch(m.route.id)) - ); - }) - ); } -function stripIndexParam(reqUrl: string) { - let url = new URL(reqUrl); +function stripIndexParam(_url: string) { + let url = new URL(_url); let indexValues = url.searchParams.getAll("index"); url.searchParams.delete("index"); let indexValuesToKeep = []; @@ -131,13 +99,65 @@ function stripIndexParam(reqUrl: string) { return url.href; } +// Determine which routes we want to load so we can add a `?_routes` search param +// for fine-grained revalidation if necessary. If a route has not yet been loaded +// via `route.lazy` then we know we want to load it because it's by definition a +// net-new route. If it has been loaded then `bikeshed_load` will have taken +// `shouldRevalidate` into consideration. +// +// There is a small edge case that _may_ result in a server loader running +// _somewhat_ unintended, but it's unavoidable: +// - Assume we have 2 routes, parent and child +// - Both have `clientLoader`'s and both need to be revalidated +// - If neither calls `serverLoader`, we won't make the single fetch call +// - We delay the single fetch call until the **first** one calls `serverLoader` +// - However, we cannot wait around to know if the other one calls +// `serverLoader`, so we include both of them in the `X-Remix-Routes` +// header +// - This means it's technically possible that the second route never calls +// `serverLoader` and we never read the response of that route from the +// single fetch call, and thus executing that `loader` on the server was +// unnecessary. +function addRevalidationParam( + manifest: AssetsManifest, + routeModules: RouteModules, + matches: DataStrategyMatch[], + _url: string +) { + let url = new URL(_url); + let genRouteIds = (arr: string[]) => + arr.filter((id) => manifest.routes[id].hasLoader).join(","); + + // By default, we don't include this param and run all matched loaders on the + // server. If _any_ of our matches include a `shouldRevalidate` function _and_ + // we've determined that the routes we need to load and the matches are + // different, then we send the header since they've opted-into fine-grained + // caching. We look at the `routeModules` here instead of the matches since + // HDR adds a wrapper for `shouldRevalidate` even if the route didn't have one + // initially. + // TODO: We probably can get rid of that wrapper once we're strictly on on + // single-fetch in v3 and just leverage a needsRevalidation data structure here + // to determine what to fetch + if (matches.some((m) => routeModules[m.route.id]?.shouldRevalidate)) { + let matchedIds = genRouteIds(matches.map((m) => m.route.id)); + let loadIds = genRouteIds( + matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) + ); + if (matchedIds !== loadIds) { + url.searchParams.set("_routes", loadIds); + } + } + + return url.href; +} + function singleFetchUrl(reqUrl: string) { let url = new URL(reqUrl); url.pathname = `${url.pathname === "/" ? "_root" : url.pathname}.data`; return url; } -async function fetchAndDecode(url: URL, init: RequestInit) { +async function fetchAndDecode(url: URL, init?: RequestInit) { let res = await fetch(url, init); invariant( res.headers.get("Content-Type")?.includes("text/x-turbo"), @@ -179,9 +199,3 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { throw new Error(`No action response found for routeId "${routeId}"`); } } - -function genRouteIds(arr: string[]) { - return arr - .filter((id) => window.__remixManifest.routes[id].hasLoader) - .join(","); -} diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 265936cb421..606ab0f54d4 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -331,7 +331,7 @@ async function handleSingleFetchRequest( ) : await singleFetchLoaders( handlerUrl, - request.headers.get("X-Remix-Routes"), + new URL(request.url).searchParams.get("_routes"), staticHandler, matches, loadContext, From c962cf7222fe6b0f6905884a683b391062fadefa Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Feb 2024 17:40:54 -0500 Subject: [PATCH 19/57] Update to latest RR --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-react/single-fetch.ts | 8 +++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 6 files changed, 25 insertions(+), 27 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 8212dee4e8a..07eb5cb5be2 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-3ba3024e", + "@remix-run/router": "0.0.0-experimental-2272fa73", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 41bb1375d2d..a5e5dd8fd45 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-3ba3024e", + "@remix-run/router": "0.0.0-experimental-2272fa73", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-3ba3024e", - "react-router-dom": "0.0.0-experimental-3ba3024e", + "react-router": "0.0.0-experimental-2272fa73", + "react-router-dom": "0.0.0-experimental-2272fa73", "turbo-stream": "^1.2.1" }, "devDependencies": { diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index 08e7302eb65..a43683e5c95 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -74,9 +74,7 @@ export function getSingleFetchDataStrategy( // be called instead of making a server call return Promise.all( matches.map(async (m) => { - return m.bikeshed_loadRoute((handler) => - handler(() => singleFetch(m.route.id)) - ); + return m.resolve((handler) => handler(() => singleFetch(m.route.id))); }) ); }; @@ -102,7 +100,7 @@ function stripIndexParam(_url: string) { // Determine which routes we want to load so we can add a `?_routes` search param // for fine-grained revalidation if necessary. If a route has not yet been loaded // via `route.lazy` then we know we want to load it because it's by definition a -// net-new route. If it has been loaded then `bikeshed_load` will have taken +// net-new route. If it has been loaded then `shouldLoad` will have taken // `shouldRevalidate` into consideration. // // There is a small edge case that _may_ result in a server loader running @@ -141,7 +139,7 @@ function addRevalidationParam( if (matches.some((m) => routeModules[m.route.id]?.shouldRevalidate)) { let matchedIds = genRouteIds(matches.map((m) => m.route.id)); let loadIds = genRouteIds( - matches.filter((m) => m.bikeshed_load).map((m) => m.route.id) + matches.filter((m) => m.shouldLoad).map((m) => m.route.id) ); if (matchedIds !== loadIds) { url.searchParams.set("_routes", loadIds); diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 69ea176a363..e5805b5a4df 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-3ba3024e", + "@remix-run/router": "0.0.0-experimental-2272fa73", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index e6319571666..3548c32967e 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-3ba3024e", - "react-router-dom": "0.0.0-experimental-3ba3024e" + "@remix-run/router": "0.0.0-experimental-2272fa73", + "react-router-dom": "0.0.0-experimental-2272fa73" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 6883828da4a..b55eb2396cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-3ba3024e": - version "0.0.0-experimental-3ba3024e" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-3ba3024e.tgz#628732fed8aac3ecdcf0d00923f32ca9c4985b06" - integrity sha512-er5jSxH6ysjJRYqvMVvh3UUeiyvjcwImimDnwk4gIMldyDIAfftrWaBSUKMzLlFcp91sv5+jPjJ1lnNCpZbvTA== +"@remix-run/router@0.0.0-experimental-2272fa73": + version "0.0.0-experimental-2272fa73" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-2272fa73.tgz#22553a59f1deb4cb5b04adf55d6207bfb0913ce3" + integrity sha512-fcEO/TSftgX4YN2RPxxHU3dKNYuFc0aiNISfMgBL2rTrHYYWh118BDHRyTQdvuh1ufzU2KMGBaQ6DTNNC5jaQw== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-3ba3024e: - version "0.0.0-experimental-3ba3024e" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-3ba3024e.tgz#7553154b9e25aa0a2e6db33bbfaf0657242523cd" - integrity sha512-HAJ0trtIrPniaTZVUfEZ1yC8IbpTtrx5toMNb+ILFg7dgRVk2g4SnFjn5iiAv0Pez/eJj5xgHEASM0mvko2DUw== +react-router-dom@0.0.0-experimental-2272fa73: + version "0.0.0-experimental-2272fa73" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-2272fa73.tgz#4c82f16bf3f4771519d3ee214095c3d250bc2b99" + integrity sha512-b5YD8fqUz+fZz9lJboNluliGHfEzUYH4f/7PknR/oKe/BfNQ+fZlMKgjQugDW8EtAIePEvSOmcoqcMOx3DOjNA== dependencies: - "@remix-run/router" "0.0.0-experimental-3ba3024e" - react-router "0.0.0-experimental-3ba3024e" + "@remix-run/router" "0.0.0-experimental-2272fa73" + react-router "0.0.0-experimental-2272fa73" -react-router@0.0.0-experimental-3ba3024e: - version "0.0.0-experimental-3ba3024e" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-3ba3024e.tgz#24c848f14cdb1751fa1545f421f0d655daa09f08" - integrity sha512-t8mtq8YFOJTHX5es1oQUFzHw59qautzDcYxiQ8CstpjR90UiXzyzzMc1LTGiayRMKHeQhvaKZrGEc31Cxom8Qw== +react-router@0.0.0-experimental-2272fa73: + version "0.0.0-experimental-2272fa73" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-2272fa73.tgz#8cf4acab994f8e1f20f53c5e816455027ae1c04a" + integrity sha512-GPaxPVjNrMaWvgego6IwbiMC8AxRcJnr5jRgH2g0mb003D0IXuVQg66KEhZVSaAxnfw07pfYpRN/RuTsgbzXFQ== dependencies: - "@remix-run/router" "0.0.0-experimental-3ba3024e" + "@remix-run/router" "0.0.0-experimental-2272fa73" react@^18.2.0: version "18.2.0" From 3d7c4eb73b701877dea62551cd5bec69a2cb78d0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 16 Feb 2024 12:54:47 -0500 Subject: [PATCH 20/57] Proxy action status back through DecodedResponse --- packages/remix-react/browser.tsx | 3 +++ packages/remix-react/single-fetch.ts | 6 +++++- packages/remix-server-runtime/server.ts | 24 +++++++++++++++++------- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 4ae8631a971..e2a5fb1c590 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -272,6 +272,9 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { v7_partialHydration: true, v7_prependBasename: true, v7_relativeSplatPath: window.__remixContext.future.v3_relativeSplatPath, + // Single fetch enables this underlying behavior + v7_skipActionErrorRevalidation: + window.__remixContext.future.unstable_singleFetch === true, }, hydrationData, mapRouteProperties, diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index a43683e5c95..adc7a131fdc 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -1,5 +1,6 @@ import type { DataStrategyMatch, ErrorResponse } from "@remix-run/router"; import { + DecodedResponse, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, redirect, } from "@remix-run/router"; @@ -12,7 +13,7 @@ import invariant from "./invariant"; import type { RouteModules } from "./routeModules"; type SingleFetchResult = - | { data: unknown } + | { data: unknown; status?: number } // status only included in actions | { error: unknown } | { redirect: string; status: number; revalidate: boolean; reload: boolean }; type SingleFetchResults = { @@ -192,6 +193,9 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { } return redirect(result.redirect, { status: result.status, headers }); } else if ("data" in result) { + if (typeof result.status === "number") { + return new DecodedResponse(result.status, "", new Headers(), result.data); + } return result.data; } else { throw new Error(`No action response found for routeId "${routeId}"`); diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 606ab0f54d4..e1f78dd7f3b 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -303,7 +303,7 @@ async function handleDataRequest( } type SingleFetchResult = - | { data: unknown } + | { data: unknown; status?: number } // status only included in actions | { error: unknown } | { redirect: string; status: number; revalidate: boolean; reload: boolean }; type SingleFetchResults = { @@ -377,8 +377,6 @@ async function singleFetchAction( }); let response = await staticHandler.queryRoute(handlerRequest, { requestContext: loadContext, - // TODO: Will need to send this in a header or something - // routeId: }); // callRouteLoader/callRouteAction always return responses invariant( @@ -396,10 +394,19 @@ async function singleFetchAction( response.headers, ]; } - return [{ data: await unwrapResponse(response) }, response.headers]; + return [ + { data: await unwrapResponse(response), status: response.status }, + response.headers, + ]; } catch (err) { handleError(err); - let error = isResponse(err) ? await unwrapResponse(err) : err; + let error = isResponse(err) + ? new ErrorResponseImpl( + err.status, + err.statusText, + await unwrapResponse(err) + ) + : err; return [{ error }, new Headers()]; } } @@ -426,9 +433,12 @@ async function singleFetchLoaders( if (isResponse(result)) { // We don't really know which loader this came from, so just stick it at // a known match - // TODO: this should take into account the revalidation header let routeId = - matches?.find((m) => m.route.module.loader)?.route.id || "root"; + matches?.find((m) => + routesToLoad + ? routesToLoad.split(",").includes(m.route.id) + : m.route.module.loader + )?.route.id || "root"; return [ { [routeId]: { From 36887bbeeed10e35eb95b0933b1e61d085c00178 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 16 Feb 2024 12:59:45 -0500 Subject: [PATCH 21/57] Bump RR experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 07eb5cb5be2..5cb3ac7dfeb 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-2272fa73", + "@remix-run/router": "0.0.0-experimental-5bedc168", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index a5e5dd8fd45..c61e6028772 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-2272fa73", + "@remix-run/router": "0.0.0-experimental-5bedc168", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-2272fa73", - "react-router-dom": "0.0.0-experimental-2272fa73", + "react-router": "0.0.0-experimental-5bedc168", + "react-router-dom": "0.0.0-experimental-5bedc168", "turbo-stream": "^1.2.1" }, "devDependencies": { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index e5805b5a4df..c80d3d0797e 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-2272fa73", + "@remix-run/router": "0.0.0-experimental-5bedc168", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index 3548c32967e..cc37a5791d4 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-2272fa73", - "react-router-dom": "0.0.0-experimental-2272fa73" + "@remix-run/router": "0.0.0-experimental-5bedc168", + "react-router-dom": "0.0.0-experimental-5bedc168" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index b55eb2396cb..9e93ae8a0ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-2272fa73": - version "0.0.0-experimental-2272fa73" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-2272fa73.tgz#22553a59f1deb4cb5b04adf55d6207bfb0913ce3" - integrity sha512-fcEO/TSftgX4YN2RPxxHU3dKNYuFc0aiNISfMgBL2rTrHYYWh118BDHRyTQdvuh1ufzU2KMGBaQ6DTNNC5jaQw== +"@remix-run/router@0.0.0-experimental-5bedc168": + version "0.0.0-experimental-5bedc168" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-5bedc168.tgz#b23502be9cbe0f4a31f1673904abf3bdb6cedd69" + integrity sha512-2pJzWv4IiJtOW4cTOg36oq9WWQVggBkV8Gtf30T6EImvoNwnVGFoFPayZghV8FvCkNZhsxC2Cjg05X7dAzo3rg== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-2272fa73: - version "0.0.0-experimental-2272fa73" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-2272fa73.tgz#4c82f16bf3f4771519d3ee214095c3d250bc2b99" - integrity sha512-b5YD8fqUz+fZz9lJboNluliGHfEzUYH4f/7PknR/oKe/BfNQ+fZlMKgjQugDW8EtAIePEvSOmcoqcMOx3DOjNA== +react-router-dom@0.0.0-experimental-5bedc168: + version "0.0.0-experimental-5bedc168" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-5bedc168.tgz#2ef4b55201f75cc21d53082264fea9a0ec25a6c6" + integrity sha512-I2/YmFIPx8fRcI1ZGjUBc0QVbWueO7wySATShixJ4UsbL1upZUX+8SkSLUUyS6UXWO3e47EtFH97zwkcSv3Ulw== dependencies: - "@remix-run/router" "0.0.0-experimental-2272fa73" - react-router "0.0.0-experimental-2272fa73" + "@remix-run/router" "0.0.0-experimental-5bedc168" + react-router "0.0.0-experimental-5bedc168" -react-router@0.0.0-experimental-2272fa73: - version "0.0.0-experimental-2272fa73" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-2272fa73.tgz#8cf4acab994f8e1f20f53c5e816455027ae1c04a" - integrity sha512-GPaxPVjNrMaWvgego6IwbiMC8AxRcJnr5jRgH2g0mb003D0IXuVQg66KEhZVSaAxnfw07pfYpRN/RuTsgbzXFQ== +react-router@0.0.0-experimental-5bedc168: + version "0.0.0-experimental-5bedc168" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-5bedc168.tgz#64089adff6441e7c0810332da0082b2a48c51327" + integrity sha512-B9P7/s20nF0fZehqcSG1StFo8RT9EdBsdNtIA4L5qDVXMqiSOAZmVaLSE8cwRT+5WM1Ul3Rtmqtzd75me6XhtQ== dependencies: - "@remix-run/router" "0.0.0-experimental-2272fa73" + "@remix-run/router" "0.0.0-experimental-5bedc168" react@^18.2.0: version "18.2.0" From 308b0100daa651ff6a66ef0d6af7d6143e6b662b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 16 Feb 2024 15:35:33 -0500 Subject: [PATCH 22/57] RR experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-react/single-fetch.ts | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 5cb3ac7dfeb..e78d3747c8f 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-5bedc168", + "@remix-run/router": "0.0.0-experimental-cbcd94b7", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index c61e6028772..e8dfb716e16 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-5bedc168", + "@remix-run/router": "0.0.0-experimental-cbcd94b7", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-5bedc168", - "react-router-dom": "0.0.0-experimental-5bedc168", + "react-router": "0.0.0-experimental-cbcd94b7", + "react-router-dom": "0.0.0-experimental-cbcd94b7", "turbo-stream": "^1.2.1" }, "devDependencies": { diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index adc7a131fdc..4f1da260dc2 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -44,13 +44,13 @@ export function getSingleFetchDataStrategy( let singleFetchPromise: Promise; let makeSingleFetchCall = async () => { - // Single fetch doesn't need/want naked index queries on action - // revalidation requests let url = singleFetchUrl( addRevalidationParam( manifest, routeModules, matches, + // Single fetch doesn't need/want naked index queries on action + // revalidation requests stripIndexParam(request.url) ) ); @@ -163,7 +163,7 @@ async function fetchAndDecode(url: URL, init?: RequestInit) { "Expected a text/x-turbo response" ); let decoded = await decode(res.body!, [ - (type, value) => { + (type: string, value: unknown) => { if (type === "ErrorResponse") { let errorResponse = value as ErrorResponse; return { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index c80d3d0797e..c154a094d8f 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-5bedc168", + "@remix-run/router": "0.0.0-experimental-cbcd94b7", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index cc37a5791d4..7b4d0b4f2c2 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-5bedc168", - "react-router-dom": "0.0.0-experimental-5bedc168" + "@remix-run/router": "0.0.0-experimental-cbcd94b7", + "react-router-dom": "0.0.0-experimental-cbcd94b7" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 9e93ae8a0ec..fbc62248e2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-5bedc168": - version "0.0.0-experimental-5bedc168" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-5bedc168.tgz#b23502be9cbe0f4a31f1673904abf3bdb6cedd69" - integrity sha512-2pJzWv4IiJtOW4cTOg36oq9WWQVggBkV8Gtf30T6EImvoNwnVGFoFPayZghV8FvCkNZhsxC2Cjg05X7dAzo3rg== +"@remix-run/router@0.0.0-experimental-cbcd94b7": + version "0.0.0-experimental-cbcd94b7" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-cbcd94b7.tgz#bfe19146d9747bd4174fb2ac21d44314a761baae" + integrity sha512-+wh6DLpGIlWIyy4mlG2JnWo1aVUxZdWX+GaZQYhaQEbiy/rf3eLvPOuGGUGJTb24VDtqoX4QhRKzqQRSL51rXg== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-5bedc168: - version "0.0.0-experimental-5bedc168" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-5bedc168.tgz#2ef4b55201f75cc21d53082264fea9a0ec25a6c6" - integrity sha512-I2/YmFIPx8fRcI1ZGjUBc0QVbWueO7wySATShixJ4UsbL1upZUX+8SkSLUUyS6UXWO3e47EtFH97zwkcSv3Ulw== +react-router-dom@0.0.0-experimental-cbcd94b7: + version "0.0.0-experimental-cbcd94b7" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-cbcd94b7.tgz#b1bd878707dd7cc89355d0a1538dfde4a820634f" + integrity sha512-HtDMBIWL9G8MmCtCMvbBLqq9DPxP56EBm4e1AHiKTud8lhq5hxpQ1S+yRMjLtDD9CO+444Y5VliLyCUXK53iEA== dependencies: - "@remix-run/router" "0.0.0-experimental-5bedc168" - react-router "0.0.0-experimental-5bedc168" + "@remix-run/router" "0.0.0-experimental-cbcd94b7" + react-router "0.0.0-experimental-cbcd94b7" -react-router@0.0.0-experimental-5bedc168: - version "0.0.0-experimental-5bedc168" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-5bedc168.tgz#64089adff6441e7c0810332da0082b2a48c51327" - integrity sha512-B9P7/s20nF0fZehqcSG1StFo8RT9EdBsdNtIA4L5qDVXMqiSOAZmVaLSE8cwRT+5WM1Ul3Rtmqtzd75me6XhtQ== +react-router@0.0.0-experimental-cbcd94b7: + version "0.0.0-experimental-cbcd94b7" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-cbcd94b7.tgz#88a1b5e24da1970e38a125e78ca5385ff390817b" + integrity sha512-+uCX6cJEV8QCdxr0X7hOt6YLOjgWfnrIjUjMU3XKuQolFc9obVKtsEgM4Q0/qEP+NRrIWNmfXRWh0G0Q0VUO8w== dependencies: - "@remix-run/router" "0.0.0-experimental-5bedc168" + "@remix-run/router" "0.0.0-experimental-cbcd94b7" react@^18.2.0: version "18.2.0" From d5bed65fd2ae12e0e70149fa070a882d0209460a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 16 Feb 2024 15:53:47 -0500 Subject: [PATCH 23/57] Fix unit tests --- packages/remix-dev/__tests__/readConfig-test.ts | 1 + packages/remix-server-runtime/__tests__/handle-error-test.ts | 1 + packages/remix-server-runtime/__tests__/handler-test.ts | 2 ++ packages/remix-server-runtime/__tests__/server-test.ts | 1 + 4 files changed, 5 insertions(+) diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index 8da38527760..b5212618150 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -36,6 +36,7 @@ describe("readConfig", () => { "entryServerFile": "entry.server.tsx", "entryServerFilePath": Any, "future": { + "unstable_singleFetch": false, "v3_fetcherPersist": false, "v3_relativeSplatPath": false, "v3_throwAbortReason": false, diff --git a/packages/remix-server-runtime/__tests__/handle-error-test.ts b/packages/remix-server-runtime/__tests__/handle-error-test.ts index 439823b5bdb..914a3fda747 100644 --- a/packages/remix-server-runtime/__tests__/handle-error-test.ts +++ b/packages/remix-server-runtime/__tests__/handle-error-test.ts @@ -25,6 +25,7 @@ function getHandler(routeModule = {}, entryServerModule = {}) { ...entryServerModule, }, }, + future: {}, } as unknown as ServerBuild; return { diff --git a/packages/remix-server-runtime/__tests__/handler-test.ts b/packages/remix-server-runtime/__tests__/handler-test.ts index 66ba9a1bfcf..414aed248f3 100644 --- a/packages/remix-server-runtime/__tests__/handler-test.ts +++ b/packages/remix-server-runtime/__tests__/handler-test.ts @@ -15,6 +15,8 @@ describe("createRequestHandler", () => { }, assets: {} as any, entry: { module: {} as any }, + // @ts-expect-error + future: {}, }); let response = await handler( diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts index 76127eb00c0..a31e23e13d8 100644 --- a/packages/remix-server-runtime/__tests__/server-test.ts +++ b/packages/remix-server-runtime/__tests__/server-test.ts @@ -55,6 +55,7 @@ describe("server", () => { }, }, }, + future: {}, } as unknown as ServerBuild; describe("createRequestHandler", () => { From 5cfdec3adbf1dd027a9a1868fb7cbfbecb3fe6ad Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 16 Feb 2024 16:56:08 -0500 Subject: [PATCH 24/57] Bump RR Experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/browser.tsx | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index e78d3747c8f..deac6ed310d 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-cbcd94b7", + "@remix-run/router": "0.0.0-experimental-de419c3d", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index e2a5fb1c590..d232f6d7e41 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -273,7 +273,7 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { v7_prependBasename: true, v7_relativeSplatPath: window.__remixContext.future.v3_relativeSplatPath, // Single fetch enables this underlying behavior - v7_skipActionErrorRevalidation: + unstable_skipActionErrorRevalidation: window.__remixContext.future.unstable_singleFetch === true, }, hydrationData, diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index e8dfb716e16..0ec07e27e8f 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-cbcd94b7", + "@remix-run/router": "0.0.0-experimental-de419c3d", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-cbcd94b7", - "react-router-dom": "0.0.0-experimental-cbcd94b7", + "react-router": "0.0.0-experimental-de419c3d", + "react-router-dom": "0.0.0-experimental-de419c3d", "turbo-stream": "^1.2.1" }, "devDependencies": { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index c154a094d8f..2421dbc9c3d 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-cbcd94b7", + "@remix-run/router": "0.0.0-experimental-de419c3d", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index 7b4d0b4f2c2..ff02b13fbb7 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-cbcd94b7", - "react-router-dom": "0.0.0-experimental-cbcd94b7" + "@remix-run/router": "0.0.0-experimental-de419c3d", + "react-router-dom": "0.0.0-experimental-de419c3d" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index fbc62248e2f..f5f054245c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-cbcd94b7": - version "0.0.0-experimental-cbcd94b7" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-cbcd94b7.tgz#bfe19146d9747bd4174fb2ac21d44314a761baae" - integrity sha512-+wh6DLpGIlWIyy4mlG2JnWo1aVUxZdWX+GaZQYhaQEbiy/rf3eLvPOuGGUGJTb24VDtqoX4QhRKzqQRSL51rXg== +"@remix-run/router@0.0.0-experimental-de419c3d": + version "0.0.0-experimental-de419c3d" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-de419c3d.tgz#8cd9f3ad572166420efc73396838325ad0e2b3cc" + integrity sha512-CxztZOOLE61lbj4VQNGQZW0bsWSCnmjZK6XU0pPi4PemrgdXrU5vpGTREaz55m4FAiZ+tSFXmjzoFEtN5A1tNw== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-cbcd94b7: - version "0.0.0-experimental-cbcd94b7" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-cbcd94b7.tgz#b1bd878707dd7cc89355d0a1538dfde4a820634f" - integrity sha512-HtDMBIWL9G8MmCtCMvbBLqq9DPxP56EBm4e1AHiKTud8lhq5hxpQ1S+yRMjLtDD9CO+444Y5VliLyCUXK53iEA== +react-router-dom@0.0.0-experimental-de419c3d: + version "0.0.0-experimental-de419c3d" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-de419c3d.tgz#cd9d1597e04c62ce2de9aa26546265001b311357" + integrity sha512-nYk1BDnR5IoL6Y17Dbq5c3AXtuGc9DgwCH6XnL7Y6ilUIeigaoYSDVcDrI/9vLza8TXaEfBZLr6rrhoDkEn8YA== dependencies: - "@remix-run/router" "0.0.0-experimental-cbcd94b7" - react-router "0.0.0-experimental-cbcd94b7" + "@remix-run/router" "0.0.0-experimental-de419c3d" + react-router "0.0.0-experimental-de419c3d" -react-router@0.0.0-experimental-cbcd94b7: - version "0.0.0-experimental-cbcd94b7" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-cbcd94b7.tgz#88a1b5e24da1970e38a125e78ca5385ff390817b" - integrity sha512-+uCX6cJEV8QCdxr0X7hOt6YLOjgWfnrIjUjMU3XKuQolFc9obVKtsEgM4Q0/qEP+NRrIWNmfXRWh0G0Q0VUO8w== +react-router@0.0.0-experimental-de419c3d: + version "0.0.0-experimental-de419c3d" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-de419c3d.tgz#c24a83b32e632954ec862d7a2a7adc6b90b395bb" + integrity sha512-NzMYhj7smxewvXyxmho7tDhSA5p5cEVOoXPYSKx2s9IuDxozuKF9jNwDnUBmKVBhTTcBeayq+byXerH6uguRdg== dependencies: - "@remix-run/router" "0.0.0-experimental-cbcd94b7" + "@remix-run/router" "0.0.0-experimental-de419c3d" react@^18.2.0: version "18.2.0" From 509863d1c7ebb46f480d928509a165a9bf5dc1b7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 29 Feb 2024 12:11:19 -0500 Subject: [PATCH 25/57] Bump RR Experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index deac6ed310d..6a62b582343 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.6.0", - "@remix-run/router": "0.0.0-experimental-de419c3d", + "@remix-run/router": "0.0.0-experimental-0141b5ec", "@remix-run/server-runtime": "2.6.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 0ec07e27e8f..4d00525d7d2 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-de419c3d", + "@remix-run/router": "0.0.0-experimental-0141b5ec", "@remix-run/server-runtime": "2.6.0", - "react-router": "0.0.0-experimental-de419c3d", - "react-router-dom": "0.0.0-experimental-de419c3d", + "react-router": "0.0.0-experimental-0141b5ec", + "react-router-dom": "0.0.0-experimental-0141b5ec", "turbo-stream": "^1.2.1" }, "devDependencies": { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 2421dbc9c3d..793e92f1929 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-de419c3d", + "@remix-run/router": "0.0.0-experimental-0141b5ec", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index ff02b13fbb7..407e47e09b3 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.6.0", "@remix-run/react": "2.6.0", - "@remix-run/router": "0.0.0-experimental-de419c3d", - "react-router-dom": "0.0.0-experimental-de419c3d" + "@remix-run/router": "0.0.0-experimental-0141b5ec", + "react-router-dom": "0.0.0-experimental-0141b5ec" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index f5f054245c0..43ae879c9f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-de419c3d": - version "0.0.0-experimental-de419c3d" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-de419c3d.tgz#8cd9f3ad572166420efc73396838325ad0e2b3cc" - integrity sha512-CxztZOOLE61lbj4VQNGQZW0bsWSCnmjZK6XU0pPi4PemrgdXrU5vpGTREaz55m4FAiZ+tSFXmjzoFEtN5A1tNw== +"@remix-run/router@0.0.0-experimental-0141b5ec": + version "0.0.0-experimental-0141b5ec" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-0141b5ec.tgz#27bfb0967bae832056ad957816bcad0a8f33f80d" + integrity sha512-EOJjGBZAfqDs9PuvnMiJUDQDaWJvwuUJ9fIM7g2lPrP9OMg8xFjSFA1wownDgbxvU4zhgnNg54UMkKtMg60MUA== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-de419c3d: - version "0.0.0-experimental-de419c3d" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-de419c3d.tgz#cd9d1597e04c62ce2de9aa26546265001b311357" - integrity sha512-nYk1BDnR5IoL6Y17Dbq5c3AXtuGc9DgwCH6XnL7Y6ilUIeigaoYSDVcDrI/9vLza8TXaEfBZLr6rrhoDkEn8YA== +react-router-dom@0.0.0-experimental-0141b5ec: + version "0.0.0-experimental-0141b5ec" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-0141b5ec.tgz#4c663d9f5dabd627a691a768608ce25565ee8fc1" + integrity sha512-Rn7sCPl93aXRIdHR1LbSRBHBLsmAhldjIfgv2OU+9FiZ9kA/0BJrl/cGB+3hocwquXPT+8GXekC2X0TZAdbMjg== dependencies: - "@remix-run/router" "0.0.0-experimental-de419c3d" - react-router "0.0.0-experimental-de419c3d" + "@remix-run/router" "0.0.0-experimental-0141b5ec" + react-router "0.0.0-experimental-0141b5ec" -react-router@0.0.0-experimental-de419c3d: - version "0.0.0-experimental-de419c3d" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-de419c3d.tgz#c24a83b32e632954ec862d7a2a7adc6b90b395bb" - integrity sha512-NzMYhj7smxewvXyxmho7tDhSA5p5cEVOoXPYSKx2s9IuDxozuKF9jNwDnUBmKVBhTTcBeayq+byXerH6uguRdg== +react-router@0.0.0-experimental-0141b5ec: + version "0.0.0-experimental-0141b5ec" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-0141b5ec.tgz#bcd779624aba333591e5891c8b544e5fc7967860" + integrity sha512-TNcVbgxTbtXfrBPH6MbEK40+HmUH2vH+nf0vKVhDFAAVYoGJpksxaONx8yzjOZHriI0hCXfv0UR+EKf+v7sImQ== dependencies: - "@remix-run/router" "0.0.0-experimental-de419c3d" + "@remix-run/router" "0.0.0-experimental-0141b5ec" react@^18.2.0: version "18.2.0" From 45f07c81739a980692774052047479f226cb4528 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 29 Feb 2024 12:13:07 -0500 Subject: [PATCH 26/57] Minor updates and fixes from E2E test runs --- packages/remix-react/routes.tsx | 36 ++++++++++++----- packages/remix-react/single-fetch.ts | 53 +++++++++++++++---------- packages/remix-server-runtime/server.ts | 1 + 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index dfe5f265978..95bca1e5c99 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -1,6 +1,9 @@ import * as React from "react"; import type { HydrationState } from "@remix-run/router"; -import { UNSAFE_ErrorResponseImpl as ErrorResponse } from "@remix-run/router"; +import { + UNSAFE_ErrorResponseImpl as ErrorResponse, + unstable_isDecodedResponse as isDecodedResponse, +} from "@remix-run/router"; import type { ActionFunctionArgs, LoaderFunctionArgs, @@ -249,18 +252,36 @@ export function createClientRoutes( return (routesByParentId[parentId] || []).map((route) => { let routeModule = routeModulesCache[route.id]; - async function fetchServerLoader( + // Fetch data from the server either via single fetch or the standard `?_data` + // request. Unwrap it when called via `serverLoader`/`serverAction` in a + // client handler, otherwise return the raw response for the router to unwrap + async function fetchServerHandlerAndMaybeUnwrap( request: Request, unwrap: boolean, singleFetch: unknown ) { - if (!route.hasLoader) return null; if (typeof singleFetch === "function") { let result = await singleFetch(); + if (unwrap && isDecodedResponse(result)) { + return result.data; + } return result; } + let result = await fetchServerHandler(request, route); - return unwrap ? unwrapServerResponse(result) : result; + if (unwrap) { + return unwrapServerResponse(result); + } + return result; + } + + async function fetchServerLoader( + request: Request, + unwrap: boolean, + singleFetch: unknown + ) { + if (!route.hasLoader) return null; + return fetchServerHandlerAndMaybeUnwrap(request, unwrap, singleFetch); } async function fetchServerAction( @@ -271,12 +292,7 @@ export function createClientRoutes( if (!route.hasAction) { throw noActionDefinedError("action", route.id); } - if (typeof singleFetch === "function") { - let result = await singleFetch(); - return result; - } - let result = await fetchServerHandler(request, route); - return unwrap ? unwrapServerResponse(result) : result; + return fetchServerHandlerAndMaybeUnwrap(request, unwrap, singleFetch); } async function prefetchStylesAndCallHandler( diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index 4f1da260dc2..c5b9dcb64c9 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -1,6 +1,6 @@ import type { DataStrategyMatch, ErrorResponse } from "@remix-run/router"; import { - DecodedResponse, + unstable_DecodedResponse, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, redirect, } from "@remix-run/router"; @@ -12,6 +12,7 @@ import type { AssetsManifest } from "./entry"; import invariant from "./invariant"; import type { RouteModules } from "./routeModules"; +// IMPORTANT! Keep in sync with the types in @remix-run/server-runtime type SingleFetchResult = | { data: unknown; status?: number } // status only included in actions | { error: unknown } @@ -158,26 +159,29 @@ function singleFetchUrl(reqUrl: string) { async function fetchAndDecode(url: URL, init?: RequestInit) { let res = await fetch(url, init); - invariant( - res.headers.get("Content-Type")?.includes("text/x-turbo"), - "Expected a text/x-turbo response" - ); - let decoded = await decode(res.body!, [ - (type: string, value: unknown) => { - if (type === "ErrorResponse") { - let errorResponse = value as ErrorResponse; - return { - value: new ErrorResponseImpl( - errorResponse.status, - errorResponse.statusText, - errorResponse.data, - (errorResponse as any).internal === true - ), - }; - } - }, - ]); - return decoded.value; + if (res.headers.get("Content-Type")?.includes("text/x-turbo")) { + let decoded = await decode(res.body!, [ + (type: string, value: unknown) => { + if (type === "ErrorResponse") { + let errorResponse = value as ErrorResponse; + return { + value: new ErrorResponseImpl( + errorResponse.status, + errorResponse.statusText, + errorResponse.data, + (errorResponse as any).internal === true + ), + }; + } + }, + ]); + return decoded.value; + } + + // If we didn't get back a turbo-stream response, then we never reached the + // Remix server and likely this is a network error - just expose up the + // response body as an Error + throw new Error(await res.text()); } function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { @@ -194,7 +198,12 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { return redirect(result.redirect, { status: result.status, headers }); } else if ("data" in result) { if (typeof result.status === "number") { - return new DecodedResponse(result.status, "", new Headers(), result.data); + return new unstable_DecodedResponse( + result.status, + "", + new Headers(), + result.data + ); } return result.data; } else { diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index e1f78dd7f3b..6d60f660e6f 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -302,6 +302,7 @@ async function handleDataRequest( } } +// IMPORTANT! Keep in sync with the types in @remix-run/react type SingleFetchResult = | { data: unknown; status?: number } // status only included in actions | { error: unknown } From b5fb702d2891ae8bf2442a091fb4f1a7787db649 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 29 Feb 2024 12:13:41 -0500 Subject: [PATCH 27/57] Duplicate a bunch of E2E tests to run with single fetch enabled --- integration/action-test.ts | 215 ++++ integration/catch-boundary-data-test.ts | 240 +++- integration/catch-boundary-test.ts | 367 ++++++ integration/client-data-test.ts | 1308 ++++++++++++++++++- integration/defer-loader-test.ts | 156 ++- integration/defer-test.ts | 1320 +++++++++++++++++++- integration/error-boundary-test.ts | 1377 +++++++++++++++++++++ integration/error-boundary-v2-test.ts | 244 ++++ integration/error-data-request-test.ts | 190 +++ integration/error-sanitization-test.ts | 557 +++++++++ integration/fetcher-test.ts | 536 ++++++++ integration/helpers/playwright-fixture.ts | 41 +- integration/loader-test.ts | 144 +++ 13 files changed, 6603 insertions(+), 92 deletions(-) diff --git a/integration/action-test.ts b/integration/action-test.ts index b3861f3a153..b6de9ef020d 100644 --- a/integration/action-test.ts +++ b/integration/action-test.ts @@ -211,3 +211,218 @@ test.describe("actions", () => { expect(await app.getHtml()).toMatch(PAGE_TEXT); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("actions", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let FIELD_NAME = "message"; + let WAITING_VALUE = "Waiting..."; + let SUBMITTED_VALUE = "Submission"; + let THROWS_REDIRECT = "redirect-throw"; + let REDIRECT_TARGET = "page"; + let PAGE_TEXT = "PAGE_TEXT"; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/urlencoded.tsx": js` + import { Form, useActionData } from "@remix-run/react"; + + export let action = async ({ request }) => { + let formData = await request.formData(); + return formData.get("${FIELD_NAME}"); + }; + + export default function Actions() { + let data = useActionData() + + return ( +
+

+ {data ? {data} : "${WAITING_VALUE}"} +

+

+ + +

+
+ ); + } + `, + + "app/routes/request-text.tsx": js` + import { Form, useActionData } from "@remix-run/react"; + + export let action = async ({ request }) => { + let text = await request.text(); + return text; + }; + + export default function Actions() { + let data = useActionData() + + return ( +
+

+ {data ? {data} : "${WAITING_VALUE}"} +

+

+ + + +

+
+ ); + } + `, + + [`app/routes/${THROWS_REDIRECT}.jsx`]: js` + import { redirect } from "@remix-run/node"; + import { Form } from "@remix-run/react"; + + export function action() { + throw redirect("/${REDIRECT_TARGET}") + } + + export default function () { + return ( +
+ +
+ ) + } + `, + + [`app/routes/${REDIRECT_TARGET}.jsx`]: js` + export default function () { + return
${PAGE_TEXT}
+ } + `, + + "app/routes/no-action.tsx": js` + import { Form } from "@remix-run/react"; + + export default function Component() { + return ( +
+ +
+ ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + let logs: string[] = []; + + test.beforeEach(({ page }) => { + page.on("console", (msg) => { + logs.push(msg.text()); + }); + }); + + test.afterEach(() => { + expect(logs).toHaveLength(0); + }); + + test("is not called on document GET requests", async () => { + let res = await fixture.requestDocument("/urlencoded"); + let html = selectHtml(await res.text(), "#text"); + expect(html).toMatch(WAITING_VALUE); + }); + + test("is called on document POST requests", async () => { + let FIELD_VALUE = "cheeseburger"; + + let params = new URLSearchParams(); + params.append(FIELD_NAME, FIELD_VALUE); + + let res = await fixture.postDocument("/urlencoded", params); + + let html = selectHtml(await res.text(), "#text"); + expect(html).toMatch(FIELD_VALUE); + }); + + test("is called on script transition POST requests", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/urlencoded`); + await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); + + await page.click("button[type=submit]"); + await page.waitForSelector("#action-text"); + await page.waitForSelector(`#text:has-text("${SUBMITTED_VALUE}")`); + }); + + test("throws a 405 when no action exists", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/no-action`); + await page.click("button[type=submit]"); + await page.waitForSelector(`h1:has-text("405 Method Not Allowed")`); + expect(logs.length).toBe(2); + expect(logs[0]).toMatch( + 'Route "routes/no-action" does not have an action' + ); + // logs[1] is the raw ErrorResponse instance from the boundary but playwright + // seems to just log the name of the constructor, which in the minified code + // is meaningless so we don't bother asserting + + // The rest of the tests in this suite assert no logs, so clear this out to + // avoid failures in afterEach + logs = []; + }); + + test("properly encodes form data for request.text() usage", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/request-text`); + await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); + + await page.click("button[type=submit]"); + await page.waitForSelector("#action-text"); + expect(await app.getHtml("#action-text")).toBe( + 'a=1&b=2' + ); + }); + + test("redirects a thrown response on document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(`/${THROWS_REDIRECT}`, params); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(`/${REDIRECT_TARGET}`); + }); + + test("redirects a thrown response on script transitions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/${THROWS_REDIRECT}`); + let responses = app.collectSingleFetchResponses(); + await app.clickSubmitButton(`/${THROWS_REDIRECT}`); + + await page.waitForSelector(`#${REDIRECT_TARGET}`); + + expect(responses.length).toBe(1); + expect(responses[0].status()).toBe(200); + + expect(new URL(page.url()).pathname).toBe(`/${REDIRECT_TARGET}`); + expect(await app.getHtml()).toMatch(PAGE_TEXT); + }); + }); +}); diff --git a/integration/catch-boundary-data-test.ts b/integration/catch-boundary-data-test.ts index 0d6d3b88b8c..708f1124e2a 100644 --- a/integration/catch-boundary-data-test.ts +++ b/integration/catch-boundary-data-test.ts @@ -28,14 +28,14 @@ let HAS_BOUNDARY_NESTED_LOADER = "/yes/loader-self-boundary" as const; let ROOT_DATA = "root data"; let LAYOUT_DATA = "root data"; -test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); +test.describe("ErrorBoundary (thrown responses)", () => { + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); }); -}); -test.describe("ErrorBoundary (thrown responses)", () => { test.beforeAll(async () => { fixture = await createFixture({ files: { @@ -242,3 +242,231 @@ test.describe("ErrorBoundary (thrown responses)", () => { ); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary (thrown responses)", () => { + test.beforeEach(async ({ context }) => { + await context.route(/.data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + useMatches, + } from "@remix-run/react"; + + export const loader = () => json("${ROOT_DATA}"); + + export default function Root() { + const data = useLoaderData(); + + return ( + + + + + + +
{data}
+ + + + + ); + } + + export function ErrorBoundary() { + let matches = useMatches(); + let { data } = matches.find(match => match.id === "root"); + + return ( + + + +
${ROOT_BOUNDARY_TEXT}
+
{data}
+ + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + export default function Index() { + return ( +
+ ${NO_BOUNDARY_LOADER} + ${HAS_BOUNDARY_LAYOUT_NESTED_LOADER} + ${HAS_BOUNDARY_NESTED_LOADER} +
+ ); + } + `, + + [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return
; + } + `, + + [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}.jsx`]: js` + import { useMatches } from "@remix-run/react"; + export function loader() { + return "${LAYOUT_DATA}"; + } + export default function Layout() { + return
; + } + export function ErrorBoundary() { + let matches = useMatches(); + let { data } = matches.find(match => match.id === "routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}"); + + return ( +
+
${LAYOUT_BOUNDARY_TEXT}
+
{data}
+
+ ); + } + `, + + [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}._index.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return
; + } + `, + + [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}.jsx`]: js` + import { Outlet, useLoaderData } from "@remix-run/react"; + export function loader() { + return "${LAYOUT_DATA}"; + } + export default function Layout() { + let data = useLoaderData(); + return ( +
+
{data}
+ +
+ ); + } + `, + + [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}._index.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return
; + } + export function ErrorBoundary() { + return ( +
${OWN_BOUNDARY_TEXT}
+ ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("renders root boundary with data available", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(401); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + expect(html).toMatch(ROOT_DATA); + }); + + test("renders root boundary with data available on transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector("#root-boundary"); + await page.waitForSelector( + `#root-boundary-data:has-text("${ROOT_DATA}")` + ); + }); + + test("renders layout boundary with data available", async () => { + let res = await fixture.requestDocument( + HAS_BOUNDARY_LAYOUT_NESTED_LOADER + ); + expect(res.status).toBe(401); + let html = await res.text(); + expect(html).toMatch(ROOT_DATA); + expect(html).toMatch(LAYOUT_BOUNDARY_TEXT); + expect(html).toMatch(LAYOUT_DATA); + }); + + test("renders layout boundary with data available on transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LAYOUT_NESTED_LOADER); + await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); + await page.waitForSelector( + `#layout-boundary:has-text("${LAYOUT_BOUNDARY_TEXT}")` + ); + await page.waitForSelector( + `#layout-boundary-data:has-text("${LAYOUT_DATA}")` + ); + }); + + test("renders self boundary with layout data available", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_NESTED_LOADER); + expect(res.status).toBe(401); + let html = await res.text(); + expect(html).toMatch(ROOT_DATA); + expect(html).toMatch(LAYOUT_DATA); + expect(html).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("renders self boundary with layout data available on transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_NESTED_LOADER); + await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); + await page.waitForSelector(`#layout-data:has-text("${LAYOUT_DATA}")`); + await page.waitForSelector( + `#own-boundary:has-text("${OWN_BOUNDARY_TEXT}")` + ); + }); + }); +}); diff --git a/integration/catch-boundary-test.ts b/integration/catch-boundary-test.ts index c92611bdf96..1817988b4d6 100644 --- a/integration/catch-boundary-test.ts +++ b/integration/catch-boundary-test.ts @@ -365,3 +365,370 @@ test.describe("ErrorBoundary (thrown responses)", () => { expect(await app.getHtml("#status")).toMatch("401"); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary (thrown responses)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let ROOT_BOUNDARY_TEXT = "ROOT_TEXT" as const; + let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT" as const; + + let HAS_BOUNDARY_LOADER = "/yes/loader" as const; + let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; + let HAS_BOUNDARY_ACTION = "/yes/action" as const; + let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; + let NO_BOUNDARY_ACTION = "/no/action" as const; + let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; + let NO_BOUNDARY_LOADER = "/no/loader" as const; + let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; + + let NOT_FOUND_HREF = "/not/found"; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts, useMatches } from "@remix-run/react"; + + export function loader() { + return json({ data: "ROOT LOADER" }); + } + + export default function Root() { + return ( + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let matches = useMatches() + return ( + + + +
${ROOT_BOUNDARY_TEXT}
+
{JSON.stringify(matches)}
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + export default function() { + return ( +
+ ${NOT_FOUND_HREF} + +
+
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + export async function action() { + throw new Response("", { status: 401 }) + } + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

+ } + export default function Index() { + return ( +
+ +
+ ); + } + `, + + [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + export function action() { + throw new Response("", { status: 401 }) + } + export default function Index() { + return ( +
+ +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` + import { useRouteError } from '@remix-run/react'; + export function loader() { + throw new Response("", { status: 401 }) + } + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +
${OWN_BOUNDARY_TEXT}
+
{error.status}
+ + ); + } + export default function Index() { + return
+ } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.child.jsx`]: js` + export function loader() { + throw new Response("", { status: 404 }) + } + export default function Index() { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }) + } + export default function Index() { + return
+ } + `, + + "app/routes/action.tsx": js` + import { Outlet, useLoaderData } from "@remix-run/react"; + + export function loader() { + return "PARENT"; + } + + export default function () { + return ( +
+

{useLoaderData()}

+ +
+ ) + } + `, + + "app/routes/action.child-catch.tsx": js` + import { Form, useLoaderData, useRouteError } from "@remix-run/react"; + + export function loader() { + return "CHILD"; + } + + export function action() { + throw new Response("Caught!", { status: 400 }); + } + + export default function () { + return ( + <> +

{useLoaderData()}

+
+ +
+ + ) + } + + export function ErrorBoundary() { + let error = useRouteError() + return

{error.status} {error.data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("non-matching urls on document requests", async () => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + let res = await fixture.requestDocument(NOT_FOUND_HREF); + expect(res.status).toBe(404); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + + // There should be no loader data on the root route + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {} }, + ]).replace(/"/g, """); + expect(html).toContain(`
${expected}
`); + + console.error = oldConsoleError; + }); + + test("non-matching urls on client transitions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NOT_FOUND_HREF, { wait: false }); + await page.waitForSelector("#root-boundary"); + + // Root loader data sticks around from previous load + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {}, data: { data: "ROOT LOADER" } }, + ]); + expect(await app.getHtml("#matches")).toContain(expected); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector("#boundary-loader"); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector("#root-boundary"); + }); + + test("uses correct catch boundary on server action errors", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-catch`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-catch"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-catch")).toMatch("400"); + expect(await app.getHtml("#child-catch")).toMatch("Caught!"); + }); + + test("prefers parent catch when child loader also bubbles, document request", async () => { + let res = await fixture.requestDocument(`${HAS_BOUNDARY_LOADER}/child`); + expect(res.status).toBe(401); + let text = await res.text(); + expect(text).toMatch(OWN_BOUNDARY_TEXT); + expect(text).toMatch('
401
'); + }); + + test("prefers parent catch when child loader also bubbles, client transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(`${HAS_BOUNDARY_LOADER}/child`); + await page.waitForSelector("#boundary-loader"); + expect(await app.getHtml("#boundary-loader")).toMatch(OWN_BOUNDARY_TEXT); + expect(await app.getHtml("#status")).toMatch("401"); + }); + }); +}); diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 7f23da815b0..412fd8b2009 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -6,7 +6,7 @@ import { createFixture, js, } from "./helpers/create-fixture.js"; -import type { AppFixture } from "./helpers/create-fixture.js"; +import type { AppFixture, FixtureInit } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; function getFiles({ @@ -145,10 +145,14 @@ test.describe("Client Data", () => { appFixture.close(); }); + function createTestFixture(init: FixtureInit, serverMode?: ServerMode) { + return createFixture(init, serverMode); + } + test.describe("clientLoader - critical route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -168,7 +172,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -188,7 +192,7 @@ test.describe("Client Data", () => { test("parent.clientLoader.hydrate/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: true, @@ -214,7 +218,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader.hydrate", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -242,7 +246,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: true, @@ -269,7 +273,7 @@ test.describe("Client Data", () => { }); test("handles synchronous client loaders", async ({ page }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -308,7 +312,7 @@ test.describe("Client Data", () => { }); test("handles deferred data through client loaders", async ({ page }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -378,7 +382,7 @@ test.describe("Client Data", () => { test("allows hydration execution without rendering a fallback", async ({ page, }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -407,7 +411,7 @@ test.describe("Client Data", () => { test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ page, }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -461,7 +465,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -504,7 +508,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -547,7 +551,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -590,7 +594,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -655,7 +659,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -732,7 +736,7 @@ test.describe("Client Data", () => { let _consoleError = console.error; console.error = () => {}; appFixture = await createAppFixture( - await createFixture( + await createTestFixture( { files: { ...getFiles({ @@ -785,7 +789,7 @@ test.describe("Client Data", () => { test.describe("clientLoader - lazy route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -807,7 +811,7 @@ test.describe("Client Data", () => { test("parent.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -828,7 +832,7 @@ test.describe("Client Data", () => { test("child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -849,7 +853,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -872,7 +876,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -916,7 +920,7 @@ test.describe("Client Data", () => { test.describe("clientAction - critical route module", () => { test("child.clientAction", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -950,7 +954,7 @@ test.describe("Client Data", () => { test("child.clientAction/parent.childLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -992,7 +996,7 @@ test.describe("Client Data", () => { test("child.clientAction/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1036,7 +1040,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1080,7 +1084,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -1125,7 +1129,7 @@ test.describe("Client Data", () => { test.describe("clientAction - lazy route module", () => { test("child.clientAction", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1161,7 +1165,7 @@ test.describe("Client Data", () => { test("child.clientAction/parent.childLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1205,7 +1209,7 @@ test.describe("Client Data", () => { test("child.clientAction/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1251,7 +1255,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1297,7 +1301,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -1341,3 +1345,1245 @@ test.describe("Client Data", () => { }); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("Client Data", () => { + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture.close(); + }); + + function createTestFixture(init: FixtureInit, serverMode?: ServerMode) { + return createFixture( + { + ...init, + config: { + future: { + unstable_singleFetch: true, + }, + }, + }, + serverMode + ); + } + + test.describe("clientLoader - critical route module", () => { + test("no client loaders or fallbacks", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + // Full SSR - normal Remix behavior due to lack of clientLoader + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + // Full SSR - normal Remix behavior due to lack of HydrateFallback components + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader.hydrate/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: true, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Fallback"); + expect(html).not.toMatch("Parent Server Loader"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Parent Fallback"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader/child.clientLoader.hydrate", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: true, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Fallback"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Child Fallback"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("parent.clientLoader.hydrate/child.clientLoader.hydrate", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: true, + childClientLoader: true, + childClientLoaderHydrate: true, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Fallback"); + expect(html).not.toMatch("Parent Server Loader"); + expect(html).not.toMatch("Child Fallback"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Parent Fallback"); + expect(html).not.toMatch("Child Fallback"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("handles synchronous client loaders", async ({ page }) => { + let fixture = await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + parentAdditions: js` + export function clientLoader() { + return { message: "Parent Client Loader" }; + } + clientLoader.hydrate=true + export function HydrateFallback() { + return

Parent Fallback

+ } + `, + childAdditions: js` + export function clientLoader() { + return { message: "Child Client Loader" }; + } + clientLoader.hydrate=true + `, + }), + }); + + // Ensure we SSR the fallbacks + let doc = await fixture.requestDocument("/parent/child"); + let html = await doc.text(); + expect(html).toMatch("Parent Fallback"); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Client Loader"); + expect(html).toMatch("Child Client Loader"); + }); + + test("handles deferred data through client loaders", async ({ page }) => { + let fixture = await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { defer, json } from '@remix-run/node' + import { Await, useLoaderData } from '@remix-run/react' + export function loader() { + return defer({ + message: 'Child Server Loader', + lazy: new Promise(r => setTimeout(() => r("Child Deferred Data"), 1000)), + }); + } + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { + ...data, + message: data.message + " (mutated by client)", + }; + } + clientLoader.hydrate = true; + export function HydrateFallback() { + return

Child Fallback

+ } + export default function Component() { + let data = useLoaderData(); + return ( + <> +

{data.message}

+ Loading Deferred Data...

}> + + {(value) =>

{value}

} +
+
+ + ); + } + `, + }, + }); + + // Ensure initial document request contains the child fallback _and_ the + // subsequent streamed/resolved deferred data + let doc = await fixture.requestDocument("/parent/child"); + let html = await doc.text(); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Fallback"); + expect(html).toMatch("Child Deferred Data"); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-deferred-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + // app.goto() doesn't resolve until the document finishes loading so by + // then the HTML has updated via the streamed suspense updates + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Deferred Data"); + }); + + test("allows hydration execution without rendering a fallback", async ({ + page, + }) => { + let fixture = await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientLoader() { + await new Promise(r => setTimeout(r, 100)); + return { message: "Child Client Loader" }; + } + clientLoader.hydrate=true + `, + }), + }); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Child Server Loader"); + await page.waitForSelector(':has-text("Child Client Loader")'); + html = await app.getHtml("main"); + expect(html).toMatch("Child Client Loader"); + }); + + test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ + page, + }) => { + let fixture = await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { useLoaderData } from '@remix-run/react'; + export function loader() { + return json({ + message: "Child Server Loader Data", + }); + } + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Child Client Loader Data", + }; + } + export function HydrateFallback() { + return

SHOULD NOT SEE ME

+ } + export default function Component() { + let data = useLoaderData(); + return

{data.message}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + // Ensure initial document request contains the child fallback _and_ the + // subsequent streamed/resolved deferred data + let doc = await fixture.requestDocument("/parent/child"); + let html = await doc.text(); + expect(html).toMatch("Child Server Loader Data"); + expect(html).not.toMatch("SHOULD NOT SEE ME"); + + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Child Server Loader Data"); + }); + + test("clientLoader.hydrate is automatically implied when no server loader exists (w HydrateFallback)", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from '@remix-run/react'; + // Even without setting hydrate=true, this should run on hydration + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Loader Data (clientLoader only)", + }; + } + export function HydrateFallback() { + return

Child Fallback

+ } + export default function Component() { + let data = useLoaderData(); + return

{data.message}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Child Fallback"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Loader Data (clientLoader only)"); + }); + + test("clientLoader.hydrate is automatically implied when no server loader exists (w/o HydrateFallback)", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from '@remix-run/react'; + // Even without setting hydrate=true, this should run on hydration + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Loader Data (clientLoader only)", + }; + } + export default function Component() { + let data = useLoaderData(); + return

{data.message}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml(); + expect(html).toMatch( + "💿 Hey developer 👋. You can provide a way better UX than this" + ); + expect(html).not.toMatch("child-data"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Loader Data (clientLoader only)"); + }); + + test("throws a 400 if you call serverLoader without a server loader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from '@remix-run/react'; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + return

Child

; + } + export function HydrateFallback() { + return

Loading...

; + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverLoader() on a route that does " + + 'not have a server loader (routeId: "routes/parent.child")' + ); + }); + + test("initial hydration data check functions properly", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { useLoaderData, useRevalidator } from '@remix-run/react'; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return json({ + message: "Child Server Loader Data (1)", + }); + } + return json({ + message: "Child Server Loader Data (2+)", + }); + } + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> +

{data.message}

+ + + ); + } + export function HydrateFallback() { + return

Loading...

+ } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml(); + expect(html).toMatch( + "Child Server Loader Data (1) (mutated by client)" + ); + app.clickElement("button"); + await page.waitForSelector( + ':has-text("Child Server Loader Data (2+)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch( + "Child Server Loader Data (2+) (mutated by client)" + ); + }); + + test("initial hydration data check functions properly even if serverLoader isn't called on hydration", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { useLoaderData, useRevalidator } from '@remix-run/react'; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return json({ + message: "Child Server Loader Data (1)", + }); + } + return json({ + message: "Child Server Loader Data (2+)", + }); + } + let isFirstClientCall = true; + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + if (isFirstClientCall) { + isFirstClientCall = false; + // First time through - don't even call serverLoader + return { + message: "Child Client Loader Data", + }; + } + // Only call the serverLoader on subsequent calls and this + // should *not* return us the initialData any longer + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> +

{data.message}

+ + + ); + } + export function HydrateFallback() { + return

Loading...

+ } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml(); + expect(html).toMatch("Child Client Loader Data"); + app.clickElement("button"); + await page.waitForSelector( + ':has-text("Child Server Loader Data (2+)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch( + "Child Server Loader Data (2+) (mutated by client)" + ); + }); + + test("server loader errors are re-thrown from serverLoader()", async ({ + page, + }) => { + let _consoleError = console.error; + console.error = () => {}; + appFixture = await createAppFixture( + await createTestFixture( + { + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import { ClientLoaderFunctionArgs, useRouteError } from "@remix-run/react"; + + export function loader() { + throw new Error("Broken!") + } + + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + clientLoader.hydrate = true; + + export default function Index() { + return

Should not see me

; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.message}

; + } + `, + }, + }, + ServerMode.Development // Avoid error sanitization + ), + ServerMode.Development // Avoid error sanitization + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Broken!"); + // Ensure we hydrate and remain on the boundary + await new Promise((r) => setTimeout(r, 100)); + html = await app.getHtml("main"); + expect(html).toMatch("Broken!"); + expect(html).not.toMatch("Should not see me"); + console.error = _consoleError; + }); + }); + + test.describe("clientLoader - lazy route module", () => { + test("no client loaders or fallbacks", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + // Normal Remix behavior due to lack of clientLoader + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + }); + + test("child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("parent.clientLoader/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client"); + }); + + test("throws a 400 if you call serverLoader without a server loader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from '@remix-run/react'; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + return

Child

; + } + export function HydrateFallback() { + return

Loading...

; + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverLoader() on a route that does " + + 'not have a server loader (routeId: "routes/parent.child")' + ); + }); + }); + + test.describe("clientAction - critical route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture( + { + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + debugger; + let data = await serverAction(); + debugger; + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }, + ServerMode.Development + ), + ServerMode.Development + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( +
+ +
+ ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); + }); + + test.describe("clientAction - lazy route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( +
+ +
+ ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.goto("/parent/child"); + await page.waitForSelector("form"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); + }); + }); +}); diff --git a/integration/defer-loader-test.ts b/integration/defer-loader-test.ts index 141c507eeb9..17b7ec0e4cd 100644 --- a/integration/defer-loader-test.ts +++ b/integration/defer-loader-test.ts @@ -11,10 +11,11 @@ import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; let fixture: Fixture; let appFixture: AppFixture; -test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` +test.describe("deferred loaders", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` import { useLoaderData, Link } from "@remix-run/react"; export default function Index() { return ( @@ -26,7 +27,7 @@ test.beforeAll(async () => { } `, - "app/routes/redirect.tsx": js` + "app/routes/redirect.tsx": js` import { defer } from "@remix-run/node"; export function loader() { return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); @@ -34,7 +35,7 @@ test.beforeAll(async () => { export default function Redirect() {return null;} `, - "app/routes/direct-promise-access.tsx": js` + "app/routes/direct-promise-access.tsx": js` import * as React from "react"; import { defer } from "@remix-run/node"; import { useLoaderData, Link, Await } from "@remix-run/react"; @@ -66,32 +67,133 @@ test.beforeAll(async () => { ) } `, - }, + }, + }); + + appFixture = await createAppFixture(fixture); }); - appFixture = await createAppFixture(fixture); -}); + test.afterAll(async () => appFixture.close()); -test.afterAll(async () => appFixture.close()); + test("deferred response can redirect on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + await page.waitForURL(/\?redirected/); + }); -test("deferred response can redirect on document request", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect"); - await page.waitForURL(/\?redirected/); -}); + test("deferred response can redirect on transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/redirect"); + await page.waitForURL(/\?redirected/); + }); -test("deferred response can redirect on transition", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/redirect"); - await page.waitForURL(/\?redirected/); + test("can directly access result from deferred promise on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/direct-promise-access"); + let element = await page.waitForSelector("[data-done]"); + expect(await element.innerText()).toMatch("hamburger 1"); + }); }); -test("can directly access result from deferred promise on document request", async ({ - page, -}) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/direct-promise-access"); - let element = await page.waitForSelector("[data-done]"); - expect(await element.innerText()).toMatch("hamburger 1"); +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("deferred loaders", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, Link } from "@remix-run/react"; + export default function Index() { + return ( +
+ Redirect + Direct Promise Access +
+ ) + } + `, + + "app/routes/redirect.tsx": js` + import { defer } from "@remix-run/node"; + export function loader() { + return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); + } + export default function Redirect() {return null;} + `, + + "app/routes/direct-promise-access.tsx": js` + import * as React from "react"; + import { defer } from "@remix-run/node"; + import { useLoaderData, Link, Await } from "@remix-run/react"; + export function loader() { + return defer({ + bar: new Promise(async (resolve, reject) => { + resolve("hamburger"); + }), + }); + } + let count = 0; + export default function Index() { + let {bar} = useLoaderData(); + React.useEffect(() => { + let aborted = false; + bar.then((data) => { + if (aborted) return; + document.getElementById("content").innerHTML = data + " " + (++count); + document.getElementById("content").setAttribute("data-done", ""); + }); + return () => { + aborted = true; + }; + }, [bar]); + return ( +
+ Waiting for client hydration.... +
+ ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => appFixture.close()); + + test("deferred response can redirect on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + await page.waitForURL(/\?redirected/); + }); + + test("deferred response can redirect on transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/redirect"); + await page.waitForURL(/\?redirected/); + }); + + test("can directly access result from deferred promise on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/direct-promise-access"); + let element = await page.waitForSelector("[data-done]"); + expect(await element.innerText()).toMatch("hamburger 1"); + }); + }); }); diff --git a/integration/defer-test.ts b/integration/defer-test.ts index 488f744a810..4eabf30c0d5 100644 --- a/integration/defer-test.ts +++ b/integration/defer-test.ts @@ -33,17 +33,17 @@ declare global { }; } -test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); -}); - test.describe("non-aborted", () => { let fixture: Fixture; let appFixture: AppFixture; + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + test.beforeAll(async () => { fixture = await createFixture({ files: { @@ -977,6 +977,13 @@ test.describe("aborted", () => { let fixture: Fixture; let appFixture: AppFixture; + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + test.beforeAll(async () => { fixture = await createFixture({ files: { @@ -1301,6 +1308,1305 @@ test.describe("aborted", () => { }); }); +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("non-aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/.data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +
+ +

{count}

+
+ ) + } + `, + "app/components/interactive.tsx": js` + import { useEffect, useState } from "react"; + + export default function Interactive() { + let [interactive, setInteractive] = useState(false); + useEffect(() => { + setInteractive(true); + }, []); + return interactive ? ( +
+

interactive

+
+ ) : null; + } + `, + "app/root.tsx": js` + import { defer } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + import Interactive from "~/components/interactive"; + + export const meta: MetaFunction = () => { + return [{ title: "New Remix App" }]; + }; + + export const loader = () => defer({ + id: "${ROOT_ID}", + }); + + export default function Root() { + let { id } = useLoaderData(); + return ( + + + + + + + + +
+

{id}

+ + + +
+ + {/* Send arbitrary data so safari renders the initial shell before + the document finishes downloading. */} + {Array(10000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { defer } from "@remix-run/node"; + import { Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + id: "${INDEX_ID}", + }); + } + + export default function Index() { + let { id } = useLoaderData(); + return ( +
+

{id}

+ + +
    +
  • deferred-script-resolved
  • +
  • deferred-script-unresolved
  • +
  • deferred-script-rejected
  • +
  • deferred-script-unrejected
  • +
  • deferred-script-rejected-no-error-element
  • +
  • deferred-script-unrejected-no-error-element
  • +
+
+ ); + } + `, + + "app/routes/deferred-noscript-resolved.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-noscript-unresolved.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-resolved.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), + deferredUndefined: Promise.resolve(undefined), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-unresolved.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + deferredUndefined: new Promise( + (resolve) => setTimeout(() => { + resolve(undefined); + }, 10) + ), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-rejected.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + +
+ } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-unrejected.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (_, reject) => setTimeout(() => { + reject(new Error("${RESOLVED_DEFERRED_ID}")); + }, 10) + ), + resolvedUndefined: new Promise( + (resolve) => setTimeout(() => { + resolve(undefined); + }, 10) + ), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId, resolvedUndefined } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + +
+ } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + + error + + + } + children={(resolvedDeferredId) => ( +
+ {"${NEVER_SHOW_ID}"} +
+ )} + /> +
+ + ); + } + `, + + "app/routes/deferred-script-rejected-no-error-element.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + + "app/routes/deferred-script-unrejected-no-error-element.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (_, reject) => setTimeout(() => { + reject(new Error("${RESOLVED_DEFERRED_ID}")); + }, 10) + ), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + + "app/routes/deferred-manual-resolve.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + global.__deferredManualResolveCache = global.__deferredManualResolveCache || { + nextId: 1, + deferreds: {}, + }; + + let id = "" + global.__deferredManualResolveCache.nextId++; + let promise = new Promise((resolve, reject) => { + global.__deferredManualResolveCache.deferreds[id] = { resolve, reject }; + }); + + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + id, + manualValue: promise, + }); + } + + export default function Deferred() { + let { deferredId, resolvedId, id, manualValue } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{id}

+ +
+ )} + /> + + manual fallback}> + + error + + + } + children={(value) => ( +
+
{JSON.stringify(value)}
+ +
+ )} + /> +
+ + ); + } + `, + + "app/routes/headers.tsx": js` + import { defer } from "@remix-run/node"; + export function loader() { + return defer({}, { headers: { "x-custom-header": "value from loader" } }); + } + export function headers({ loaderHeaders }) { + return { + "x-custom-header": loaderHeaders.get("x-custom-header") + } + } + export default function Component() { + return ( +
Headers
+ ) + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("works with critical JSON like data", async ({ page }) => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(ROOT_ID); + expect(criticalHTML).toContain(INDEX_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toBe(""); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${INDEX_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await assertConsole(); + }); + + test("resolved promises render in initial payload", async ({ page }) => { + let response = await fixture.requestDocument( + "/deferred-noscript-resolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(ROOT_ID); + expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).not.toContain(FALLBACK_ID); + expect(criticalHTML).toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toBe(""); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-resolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); + + test("slow promises render in subsequent payload", async ({ page }) => { + let response = await fixture.requestDocument( + "/deferred-noscript-unresolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(ROOT_ID); + expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(RESOLVED_DEFERRED_ID); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-unresolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); + + test("resolved promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-resolved"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(ROOT_ID); + expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).not.toContain(FALLBACK_ID); + expect(criticalHTML).toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toBe(""); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-resolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("slow to resolve promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument( + "/deferred-script-unresolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(ROOT_ID); + expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(RESOLVED_DEFERRED_ID); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unresolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("rejected promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-rejected"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(ROOT_ID); + expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).not.toContain(FALLBACK_ID); + expect(criticalHTML).toContain(ERROR_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toBe(""); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-rejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + + await assertConsole(); + }); + + test("slow to reject promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument( + "/deferred-script-unrejected" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(ROOT_ID); + expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).not.toContain(ERROR_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(ERROR_ID); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unrejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + await page.waitForSelector(`#${UNDEFINED_ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, UNDEFINED_ERROR_ID); + + await assertConsole(); + }); + + test("rejected promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-rejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + + test("slow to reject promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-unrejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + + test("routes are interactive when deferred promises are suspended and after resolve in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + app.goto("/deferred-manual-resolve"); + + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); + + // Ensure the deferred promise is suspended + await page.waitForSelector(`#${MANUAL_RESOLVED_ID}`, { state: "hidden" }); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + global.__deferredManualResolveCache.deferreds[id].resolve("value"); + + await ensureInteractivity(page, MANUAL_RESOLVED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("routes are interactive when deferred promises are suspended and after rejection in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-manual-resolve"); + + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + global.__deferredManualResolveCache.deferreds[id].reject( + new Error("error") + ); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, MANUAL_ERROR_ID); + + await assertConsole(); + }); + + test("client transition with resolved promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-resolved"); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("client transition with unresolved promises work", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unresolved"); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("client transition with rejected promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + app.clickLink("/deferred-script-rejected"); + + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("client transition with unrejected promises work", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unrejected"); + + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, UNDEFINED_ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("client transition with rejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-rejected-no-error-element"); + + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); + }); + + test("client transition with unrejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unrejected-no-error-element"); + + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); + }); + + test("returns headers on document requests", async ({ page }) => { + let response = await fixture.requestDocument("/headers"); + expect(response.headers.get("x-custom-header")).toEqual( + "value from loader" + ); + }); + + test("returns headers on data requests", async ({ page }) => { + let response = await fixture.requestData("/headers", "routes/headers"); + expect(response.headers.get("x-custom-header")).toEqual( + "value from loader" + ); + }); + }); + + test.describe("aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + import type { AppLoadContext, EntryContext } from "@remix-run/node"; + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer } from "@remix-run/react"; + import { isbot } from "isbot"; + import { renderToPipeableStream } from "react-dom/server"; + + const ABORT_DELAY = 1; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext + ) { + return isbot(request.headers.get("user-agent") || "") + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); + } + + function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let didError = false; + + let { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + let body = new PassThrough(); + let stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); + } + + function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let didError = false; + + let { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + let body = new PassThrough(); + let stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(err: unknown) { + reject(err); + }, + onError(error: unknown) { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); + } + `, + "app/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +
+ +

{count}

+
+ ) + } + `, + "app/components/interactive.tsx": js` + import { useEffect, useState } from "react"; + + export default function Interactive() { + let [interactive, setInteractive] = useState(false); + useEffect(() => { + setInteractive(true); + }, []); + return interactive ? ( +
+

interactive

+
+ ) : null; + } + `, + "app/root.tsx": js` + import { defer } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + import Interactive from "~/components/interactive"; + + export const meta: MetaFunction = () => { + return [{ title: "New Remix App" }]; + }; + + export const loader = () => defer({ + id: "${ROOT_ID}", + }); + + export default function Root() { + let { id } = useLoaderData(); + return ( + + + + + + + + +
+

{id}

+ + + +
+ + {/* Send arbitrary data so safari renders the initial shell before + the document finishes downloading. */} + {Array(6000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} + + + ); + } + `, + + "app/routes/deferred-server-aborted.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10000) + ), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + + + } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + `, + + "app/routes/deferred-server-aborted-no-error-element.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + import Counter from "~/components/counter"; + + export function loader() { + return defer({ + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10000) + ), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("server aborts render the errorElement", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + }); + + test("server aborts render the ErrorBoundary when no errorElement", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted-no-error-element"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + }); +}); + async function ensureInteractivity(page: Page, id: string, expect: number = 1) { await page.waitForSelector("#interactive"); let increment = await page.waitForSelector("#increment-" + id); diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts index c01c0f7588d..9d2166baa62 100644 --- a/integration/error-boundary-test.ts +++ b/integration/error-boundary-test.ts @@ -1329,3 +1329,1380 @@ test("Allows back-button out of an error boundary after a hard reload", async ({ appFixture.close(); console.error = _consoleError; }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + + let ROOT_BOUNDARY_TEXT = "ROOT_BOUNDARY_TEXT"; + let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT"; + + let HAS_BOUNDARY_LOADER = "/yes/loader" as const; + let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; + let HAS_BOUNDARY_ACTION = "/yes/action" as const; + let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; + let HAS_BOUNDARY_RENDER = "/yes/render" as const; + let HAS_BOUNDARY_RENDER_FILE = "/yes.render" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION = "/yes/no-loader-or-action" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE = + "/yes.no-loader-or-action" as const; + + let NO_BOUNDARY_ACTION = "/no/action" as const; + let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; + let NO_BOUNDARY_LOADER = "/no/loader" as const; + let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; + let NO_BOUNDARY_RENDER = "/no/render" as const; + let NO_BOUNDARY_RENDER_FILE = "/no.render" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION = "/no/no-loader-or-action" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE = + "/no.no-loader-or-action" as const; + + let NOT_FOUND_HREF = "/not/found"; + + // packages/remix-react/errorBoundaries.tsx + let INTERNAL_ERROR_BOUNDARY_HEADING = "Application Error"; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + export function ErrorBoundary() { + return ( + + + +
+
${ROOT_BOUNDARY_TEXT}
+
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + export default function () { + return ( +
+ ${NOT_FOUND_HREF} + +
+ + + + +
+ + + ${HAS_BOUNDARY_LOADER} + + + ${NO_BOUNDARY_LOADER} + + + ${HAS_BOUNDARY_RENDER} + + + ${NO_BOUNDARY_RENDER} + +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + export async function action() { + throw new Error("Kaboom!") + } + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

+ } + export default function () { + return ( +
+ +
+ ); + } + `, + + [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + export function action() { + throw new Error("Kaboom!") + } + export default function () { + return ( +
+ +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Error("Kaboom!") + } + export function ErrorBoundary() { + return
${OWN_BOUNDARY_TEXT}
+ } + export default function () { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Error("Kaboom!") + } + export default function () { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_RENDER_FILE}.jsx`]: js` + export default function () { + throw new Error("Kaboom!") + return
+ } + `, + + [`app/routes${HAS_BOUNDARY_RENDER_FILE}.jsx`]: js` + export default function () { + throw new Error("Kaboom!") + return
+ } + + export function ErrorBoundary() { + return
${OWN_BOUNDARY_TEXT}
+ } + `, + + [`app/routes${HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE}.jsx`]: js` + export function ErrorBoundary() { + return
${OWN_BOUNDARY_TEXT}
+ } + export default function Index() { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE}.jsx`]: js` + export default function Index() { + return
+ } + `, + + "app/routes/fetcher-boundary.tsx": js` + import { useFetcher } from "@remix-run/react"; + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

+ } + export default function() { + let fetcher = useFetcher(); + + return ( +
+ +
+ ) + } + `, + + "app/routes/fetcher-no-boundary.tsx": js` + import { useFetcher } from "@remix-run/react"; + export default function() { + let fetcher = useFetcher(); + + return ( +
+ + + +
+ ) + } + `, + + "app/routes/action.tsx": js` + import { Outlet, useLoaderData } from "@remix-run/react"; + + export function loader() { + return "PARENT"; + } + + export default function () { + return ( +
+

{useLoaderData()}

+ +
+ ) + } + `, + + "app/routes/action.child-error.tsx": js` + import { Form, useLoaderData, useRouteError } from "@remix-run/react"; + + export function loader() { + return "CHILD"; + } + + export function action() { + throw new Error("Broken!"); + } + + export default function () { + return ( + <> +

{useLoaderData()}

+
+ +
+ + ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.message}

; + } + `, + }, + }, + ServerMode.Development + ); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + console.error = _consoleError; + appFixture.close(); + }); + + test("invalid request methods", async () => { + let res = await fixture.requestDocument("/", { method: "OPTIONS" }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("ssr rendering errors with no boundary", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_RENDER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("script transition rendering errors with no boundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_RENDER); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("ssr rendering errors with boundary", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_RENDER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("script transition rendering errors with boundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_RENDER); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("uses correct error boundary on server action errors in nested routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-error`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-error"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-error")).toMatch("Broken!"); + }); + + test("renders own boundary in fetcher action submission without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-boundary"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector("#fetcher-boundary"); + }); + + test("renders root boundary in fetcher action submission without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-no-boundary"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + }); + + test("renders root boundary in document POST without action requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_NO_LOADER_OR_ACTION, { + method: "post", + }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("renders root boundary in action script transitions without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + }); + + test("renders own boundary in document POST without action requests", async () => { + let res = await fixture.requestDocument( + HAS_BOUNDARY_NO_LOADER_OR_ACTION, + { + method: "post", + } + ); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("renders own boundary in action script transitions without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector("#boundary-no-loader-or-action"); + }); + + test.describe("if no error boundary exists in the app", () => { + let NO_ROOT_BOUNDARY_LOADER = "/loader-bad" as const; + let NO_ROOT_BOUNDARY_ACTION = "/action-bad" as const; + let NO_ROOT_BOUNDARY_LOADER_RETURN = "/loader-no-return" as const; + let NO_ROOT_BOUNDARY_ACTION_RETURN = "/action-no-return" as const; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + + export default function () { + return ( +
+

Home

+ Loader no return +
+ + +
+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_LOADER}.jsx`]: js` + export async function loader() { + throw Error("BLARGH"); + } + + export default function () { + return ( +
+

Hello

+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_ACTION}.jsx`]: js` + export async function action() { + throw Error("YOOOOOOOO WHAT ARE YOU DOING"); + } + + export default function () { + return ( +
+

Goodbye

+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_LOADER_RETURN}.jsx`]: js` + import { useLoaderData } from "@remix-run/react"; + + export async function loader() {} + + export default function () { + let data = useLoaderData(); + return ( +
+

{data}

+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_ACTION_RETURN}.jsx`]: js` + import { useActionData } from "@remix-run/react"; + + export async function action() {} + + export default function () { + let data = useActionData(); + return ( +
+

{data}

+
+ ) + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test("bubbles to internal boundary in loader document requests", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_ROOT_BOUNDARY_LOADER); + expect(await app.getHtml("h1")).toMatch( + INTERNAL_ERROR_BOUNDARY_HEADING + ); + }); + + test("bubbles to internal boundary in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION); + await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); + expect(await app.getHtml("h1")).toMatch( + INTERNAL_ERROR_BOUNDARY_HEADING + ); + }); + + test("bubbles to internal boundary if loader doesn't return (document requests)", async () => { + let res = await fixture.requestDocument(NO_ROOT_BOUNDARY_LOADER_RETURN); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + + test("bubbles to internal boundary if loader doesn't return", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_ROOT_BOUNDARY_LOADER_RETURN); + await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); + expect(await app.getHtml("h1")).toMatch( + INTERNAL_ERROR_BOUNDARY_HEADING + ); + }); + + test("bubbles to internal boundary if action doesn't return (document requests)", async () => { + let res = await fixture.requestDocument( + NO_ROOT_BOUNDARY_ACTION_RETURN, + { + method: "post", + } + ); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + + test("bubbles to internal boundary if action doesn't return", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION_RETURN); + await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); + expect(await app.getHtml("h1")).toMatch( + INTERNAL_ERROR_BOUNDARY_HEADING + ); + }); + }); + }); + + test.describe("loaderData in ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let consoleErrors: string[]; + let oldConsoleError: () => void; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet, useLoaderData, useMatches, useRouteError } from "@remix-run/react"; + + export function loader() { + return "PARENT"; + } + + export default function () { + return ( +
+

{useLoaderData()}

+ +
+ ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +

{useLoaderData()}

+

+ {useMatches().find(m => m.id === 'routes/parent').data} +

+

{error.message}

+ + ); + } + `, + + "app/routes/parent.child-with-boundary.tsx": js` + import { Form, useLoaderData, useRouteError } from "@remix-run/react"; + + export function loader() { + return "CHILD"; + } + + export function action() { + throw new Error("Broken!"); + } + + export default function () { + return ( + <> +

{useLoaderData()}

+
+ +
+ + ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +

{useLoaderData()}

+

{error.message}

+ + ); + } + `, + + "app/routes/parent.child-without-boundary.tsx": js` + import { Form, useLoaderData } from "@remix-run/react"; + + export function loader() { + return "CHILD"; + } + + export function action() { + throw new Error("Broken!"); + } + + export default function () { + return ( + <> +

{useLoaderData()}

+
+ +
+ + ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + consoleErrors = []; + // Listen for all console events and handle errors + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + }); + + function runBoundaryTests() { + test("Prevents useLoaderData in self ErrorBoundary", async ({ + page, + javaScriptEnabled, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child-with-boundary"); + + expect(await app.getHtml("#parent-data")).toEqual( + '

PARENT

' + ); + expect(await app.getHtml("#child-data")).toEqual( + '

CHILD

' + ); + expect(consoleErrors).toEqual([]); + + await app.clickSubmitButton("/parent/child-with-boundary"); + await page.waitForSelector("#child-error"); + + expect(await app.getHtml("#child-error")).toEqual( + '

Broken!

' + ); + expect(await app.getHtml("#parent-data")).toEqual( + '

PARENT

' + ); + expect(await app.getHtml("#child-data")).toEqual( + '

' + ); + + // Only look for this message. Chromium browsers will also log the + // network error but firefox does not + // "Failed to load resource: the server responded with a status of 500 (Internal Server Error)", + let msg = + "You cannot `useLoaderData` in an errorElement (routeId: routes/parent.child-with-boundary)"; + if (javaScriptEnabled) { + expect(consoleErrors.filter((m) => m === msg)).toEqual([msg]); + } else { + // We don't get the useLoaderData message in the client when JS is disabled + expect(consoleErrors.filter((m) => m === msg)).toEqual([]); + } + }); + + test("Prevents useLoaderData in bubbled ErrorBoundary", async ({ + page, + javaScriptEnabled, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child-without-boundary"); + + expect(await app.getHtml("#parent-data")).toEqual( + '

PARENT

' + ); + expect(await app.getHtml("#child-data")).toEqual( + '

CHILD

' + ); + expect(consoleErrors).toEqual([]); + + await app.clickSubmitButton("/parent/child-without-boundary"); + await page.waitForSelector("#parent-error"); + + expect(await app.getHtml("#parent-error")).toEqual( + '

Broken!

' + ); + expect(await app.getHtml("#parent-matches-data")).toEqual( + '

' + ); + expect(await app.getHtml("#parent-data")).toEqual( + '

' + ); + + // Only look for this message. Chromium browsers will also log the + // network error but firefox does not + // "Failed to load resource: the server responded with a status of 500 (Internal Server Error)", + let msg = + "You cannot `useLoaderData` in an errorElement (routeId: routes/parent)"; + if (javaScriptEnabled) { + expect(consoleErrors.filter((m) => m === msg)).toEqual([msg]); + } else { + // We don't get the useLoaderData message in the client when JS is disabled + expect(consoleErrors.filter((m) => m === msg)).toEqual([]); + } + }); + } + }); + + test.describe("Default ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + + function getFiles({ + includeRootErrorBoundary = false, + rootErrorBoundaryThrows = false, + } = {}) { + let errorBoundaryCode = !includeRootErrorBoundary + ? "" + : rootErrorBoundaryThrows + ? js` + export function ErrorBoundary() { + let error = useRouteError(); + return ( + + + +
+
Root Error Boundary
+

{error.message}

+

{oh.no.what.have.i.done}

+
+ + + + ) + } + ` + : js` + export function ErrorBoundary() { + let error = useRouteError(); + return ( + + + +
+
Root Error Boundary
+

{error.message}

+
+ + + + ) + } + `; + + return { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useRouteError } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + ${errorBoundaryCode} + `, + + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + export default function () { + return ( +
+

Index

+ Loader Error + Render Error +
+ ); + } + `, + + "app/routes/loader-error.tsx": js` + export function loader() { + throw new Error('Loader Error'); + } + export default function () { + return

Loader Error

+ } + `, + + "app/routes/render-error.tsx": js` + export default function () { + throw new Error("Render Error") + } + `, + }; + } + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + }); + + test.afterAll(async () => { + console.error = _consoleError; + appFixture.close(); + }); + + test.describe("When the root route does not have a boundary", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: getFiles({ includeRootErrorBoundary: false }), + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => appFixture.close()); + + test.describe("document requests", () => { + test("renders default boundary on loader errors", async () => { + let res = await fixture.requestDocument("/loader-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Application Error"); + expect(text).toMatch("Loader Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + + test("renders default boundary on render errors", async () => { + let res = await fixture.requestDocument("/render-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Application Error"); + expect(text).toMatch("Render Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + }); + + test.describe("SPA navigations", () => { + test("renders default boundary on loader errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/loader-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + expect(html).toMatch("Loader Error"); + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + + test("renders default boundary on render errors", async ({ + page, + }, workerInfo) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/render-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + // Chromium seems to be the only one that includes the message in the stack + if (workerInfo.project.name === "chromium") { + expect(html).toMatch("Render Error"); + } + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + }); + }); + + test.describe("When the root route has a boundary", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: getFiles({ includeRootErrorBoundary: true }), + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => appFixture.close()); + + test.describe("document requests", () => { + test("renders root boundary on loader errors", async () => { + let res = await fixture.requestDocument("/loader-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Root Error Boundary"); + expect(text).toMatch("Loader Error"); + expect(text).not.toMatch("Application Error"); + }); + + test("renders root boundary on render errors", async () => { + let res = await fixture.requestDocument("/render-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Root Error Boundary"); + expect(text).toMatch("Render Error"); + expect(text).not.toMatch("Application Error"); + }); + }); + + test.describe("SPA navigations", () => { + test("renders root boundary on loader errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/loader-error"); + await page.waitForSelector("#root-error-boundary"); + let html = await app.getHtml(); + expect(html).toMatch("Root Error Boundary"); + expect(html).toMatch("Loader Error"); + expect(html).not.toMatch("Application Error"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + + test("renders root boundary on render errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/render-error"); + await page.waitForSelector("#root-error-boundary"); + let html = await app.getHtml(); + expect(html).toMatch("Root Error Boundary"); + expect(html).toMatch("Render Error"); + expect(html).not.toMatch("Application Error"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + }); + }); + + test.describe("When the root route has a boundary but it also throws 😦", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: getFiles({ + includeRootErrorBoundary: true, + rootErrorBoundaryThrows: true, + }), + }); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => appFixture.close()); + + test.describe("document requests", () => { + test("tries to render root boundary on loader errors but bubbles to default boundary", async () => { + let res = await fixture.requestDocument("/loader-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Unexpected Server Error"); + expect(text).not.toMatch("Application Error"); + expect(text).not.toMatch("Loader Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + + test("tries to render root boundary on render errors but bubbles to default boundary", async () => { + let res = await fixture.requestDocument("/render-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Unexpected Server Error"); + expect(text).not.toMatch("Application Error"); + expect(text).not.toMatch("Render Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + }); + + test.describe("SPA navigations", () => { + test("tries to render root boundary on loader errors but bubbles to default boundary", async ({ + page, + }, workerInfo) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/loader-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + if (workerInfo.project.name === "chromium") { + expect(html).toMatch("ReferenceError: oh is not defined"); + } + expect(html).not.toMatch("Loader Error"); + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + + test("tries to render root boundary on render errors but bubbles to default boundary", async ({ + page, + }, workerInfo) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/render-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + if (workerInfo.project.name === "chromium") { + expect(html).toMatch("ReferenceError: oh is not defined"); + } + expect(html).not.toMatch("Render Error"); + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + }); + }); + }); + + test("Allows back-button out of an error boundary after a hard reload", async ({ + page, + browserName, + }) => { + let _consoleError = console.error; + console.error = () => {}; + + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useRouteError } from "@remix-run/react"; + + export default function App() { + return ( + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + + + Oh no! + + + + +

ERROR BOUNDARY

+ + + + ); + } + `, + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + + export default function Index() { + return ( +
+

INDEX

+ This will error +
+ ); + } + `, + + "app/routes/boom.tsx": js` + import { json } from "@remix-run/node"; + export function loader() { return boom(); } + export default function() { return my page; } + `, + }, + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await page.waitForSelector("#index"); + expect(app.page.url()).not.toMatch("/boom"); + + await app.clickLink("/boom"); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("/boom"); + + await app.reload(); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("boom"); + + await app.goBack(); + + // Here be dragons + // - Playwright sets the Firefox `fission.webContentIsolationStrategy=0` preference + // for reasons having to do with out-of-process iframes: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1543287282 + // - That preference exposes a bug in firefox where a hard reload adds to the + // history stack: https://bugzilla.mozilla.org/show_bug.cgi?id=1832341 + // - Your can disable this preference via the Playwright `firefoxUserPrefs` config, + // but that is broken until 1.34: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1546230104 + // https://github.com/microsoft/playwright/issues/15405 + // - We can't yet upgrade to 1.34 because it drops support for Node 14: + // https://github.com/microsoft/playwright/releases/tag/v1.34.0 + // + // So for now when in firefox we just navigate back twice to work around the issue + if (browserName === "firefox") { + await app.goBack(); + } + + await page.waitForSelector("#index"); + expect(app.page.url()).not.toContain("boom"); + + appFixture.close(); + console.error = _consoleError; + }); +}); diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts index 2d4ba38a3d6..1d047788431 100644 --- a/integration/error-boundary-v2-test.ts +++ b/integration/error-boundary-v2-test.ts @@ -241,6 +241,250 @@ test.describe("ErrorBoundary", () => { } }); +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let oldConsoleError: () => void; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { + Link, + Outlet, + isRouteErrorResponse, + useLoaderData, + useRouteError, + } from "@remix-run/react"; + + export function loader() { + return "PARENT LOADER"; + } + + export default function Component() { + return ( +
+ +

{useLoaderData()}

+ +
+ ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ? +

{error.status + ' ' + error.data}

: +

{error.message}

; + } + `, + + "app/routes/parent.child-with-boundary.tsx": js` + import { + isRouteErrorResponse, + useLoaderData, + useLocation, + useRouteError, + } from "@remix-run/react"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return

{data}

; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ? +

{error.status + ' ' + error.data}

: +

{error.message}

; + } + `, + + "app/routes/parent.child-without-boundary.tsx": js` + import { useLoaderData, useLocation } from "@remix-run/react"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return

{data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + + test("Network errors that never reach the Remix server", async ({ + page, + }) => { + page.on("console", (msg) => { + console.log(msg.text()); + }); + // Cause a ?_data request to trigger an HTTP error that never reaches the + // Remix server, and ensure we properly handle it at the ErrorBoundary + await page.route(/\/parent\/child-with-boundary\.data$/, (route) => { + console.log("fulfilling!"); + route.fulfill({ status: 500, body: "CDN Error!" }); + }); + let app = new PlaywrightFixture(appFixture, page); + // await app.poke(120000, "/parent"); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-error", "CDN Error!"); + }); + }); + + function runBoundaryTests() { + test("No errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-data", "CHILD LOADER"); + }); + + test("Throwing a Response to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#child-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=error"); + await waitForAndAssert(page, app, "#child-error", "Loader Error"); + }); + + test("Throwing a render error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=render"); + await waitForAndAssert(page, app, "#child-error", "Render Error"); + }); + + test("Throwing a Response to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#parent-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=error"); + await waitForAndAssert(page, app, "#parent-error", "Loader Error"); + }); + + test("Throwing a render error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=render"); + await waitForAndAssert(page, app, "#parent-error", "Render Error"); + }); + } + }); +}); + // Shorthand util to wait for an element to appear before asserting it async function waitForAndAssert( page: Page, diff --git a/integration/error-data-request-test.ts b/integration/error-data-request-test.ts index e8daab764cc..5aa25ff2255 100644 --- a/integration/error-data-request-test.ts +++ b/integration/error-data-request-test.ts @@ -173,3 +173,193 @@ test.describe("ErrorBoundary", () => { assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + let errorLogs: any[]; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = (v) => errorLogs.push(v); + + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + + export default function () { + return

Index

+ } + `, + + [`app/routes/loader-throw-error.jsx`]: js` + export async function loader() { + throw Error("BLARGH"); + } + + export default function () { + return

Hello

+ } + `, + + [`app/routes/loader-return-json.jsx`]: js` + import { json } from "@remix-run/server-runtime"; + + export async function loader() { + return json({ ok: true }); + } + + export default function () { + return

Hello

+ } + `, + + [`app/routes/action-throw-error.jsx`]: js` + export async function action() { + throw Error("YOOOOOOOO WHAT ARE YOU DOING"); + } + + export default function () { + return

Goodbye

; + } + `, + + [`app/routes/action-return-json.jsx`]: js` + import { json } from "@remix-run/server-runtime"; + + export async function action() { + return json({ ok: true }); + } + + export default function () { + return

Hi!

+ } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.beforeEach(async () => { + errorLogs = []; + }); + + test.afterAll(() => { + console.error = _consoleError; + appFixture.close(); + }); + + function assertLoggedErrorInstance(message: string) { + let error = errorLogs[0] as Error; + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual(message); + } + + test("returns a 400 x-remix-error on a data fetch to a path with no loader", async () => { + let response = await fixture.requestData("/", "routes/_index"); + expect(response.status).toBe(400); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + expect(errorLogs[0]).toBeInstanceOf(Error); + assertLoggedErrorInstance( + 'You made a GET request to "/" but did not provide a `loader` for route "routes/_index", so there is no way to handle the request.' + ); + }); + + test("returns a 405 x-remix-error on a data fetch POST to a path with no action", async () => { + let response = await fixture.requestData("/?index", "routes/_index", { + method: "POST", + }); + expect(response.status).toBe(405); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance( + 'You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + ); + }); + + test("returns a 405 x-remix-error on a data fetch with a bad method", async () => { + expect(() => + fixture.requestData( + "/loader-return-json", + "routes/loader-return-json", + { + method: "TRACE", + } + ) + ).rejects.toThrowError( + `Failed to construct 'Request': 'TRACE' HTTP method is unsupported.` + ); + }); + + test("returns a 403 x-remix-error on a data fetch GET to a bad path", async () => { + // just headers content-type mismatch but differs from POST below + let response = await fixture.requestData( + "/", + "routes/loader-return-json" + ); + expect(response.status).toBe(403); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance( + 'Route "routes/loader-return-json" does not match URL "/"' + ); + }); + + test("returns a 403 x-remix-error on a data fetch POST to a bad path", async () => { + let response = await fixture.requestData( + "/", + "routes/loader-return-json", + { + method: "POST", + } + ); + expect(response.status).toBe(403); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance( + 'Route "routes/loader-return-json" does not match URL "/"' + ); + }); + + test("returns a 404 x-remix-error on a data fetch to a path with no matches", async () => { + let response = await fixture.requestData( + "/i/match/nothing", + "routes/junk" + ); + expect(response.status).toBe(404); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); + }); + }); +}); diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 63f57fc3a18..813a4d8a113 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -660,3 +660,560 @@ test.describe("Error Sanitization", () => { }); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("Error Sanitization", () => { + let fixture: Fixture; + let oldConsoleError: () => void; + let errorLogs: any[] = []; + + test.beforeEach(() => { + oldConsoleError = console.error; + errorLogs = []; + console.error = (...args) => errorLogs.push(args); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("serverMode=production", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: routeFiles, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch('{"message":"Unexpected Server Error"}'); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).toMatch("x.stack=undefined;"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/_index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/_index"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let response = await fixture.requestData( + "/defer?loader", + "routes/defer" + ); + let text = await response.text(); + expect(text).toBe( + '{"lazy":"__deferred_promise:lazy"}\n\n' + + 'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n' + ); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestData( + "/resource?loader", + "routes/resource" + ); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch( + 'Route "not-a-route" does not match URL "/"' + ); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not support hydration of Error subclasses", async ({ + page, + }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + + // Hydration + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + }); + }); + + test.describe("serverMode=development", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: routeFiles, + }, + ServerMode.Development + ); + }); + let ogEnv = process.env.NODE_ENV; + test.beforeEach(() => { + ogEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + }); + test.afterEach(() => { + process.env.NODE_ENV = ogEnv; + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("

MESSAGE:Loader Error"); + expect(html).toMatch("

STACK:Error: Loader Error"); + expect(html).toMatch( + 'errors":{"routes/_index":{"message":"Loader Error","stack":"Error: Loader Error\\n' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("

MESSAGE:Render Error"); + expect(html).toMatch("

STACK:Error: Render Error"); + expect(html).toMatch( + 'errors":{"routes/_index":{"message":"Render Error","stack":"Error: Render Error\\n' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/"stack":/i); + }); + + test("does not sanitize defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).toMatch("x.stack=e.stack;"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/_index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/_index"); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Loader Error","stack":"Error: Loader Error' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in deferred data requests", async () => { + let response = await fixture.requestData( + "/defer?loader", + "routes/defer" + ); + let text = await response.text(); + expect(text).toMatch( + 'error:{"lazy":{"message":"REJECTED","stack":"Error: REJECTED' + ); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("does not sanitize loader errors in resource requests", async () => { + let response = await fixture.requestData( + "/resource?loader", + "routes/resource" + ); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Loader Error","stack":"Error: Loader Error' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Route \\"not-a-route\\" does not match URL \\"/\\"","stack":"Error: Route \\"not-a-route\\" does not match URL \\"/\\"' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch( + 'Route "not-a-route" does not match URL "/"' + ); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("supports hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "

STACK:ReferenceError: thisisnotathing is not defined" + ); + + // Hydration + let appFixture = await createAppFixture( + fixture, + ServerMode.Development + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "STACK:ReferenceError: thisisnotathing is not defined" + ); + }); + }); + + test.describe("serverMode=production (user-provided handleError)", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/entry.server.tsx": js` + import type { EntryContext } from "@remix-run/node"; + import { RemixServer, isRouteErrorResponse } from "@remix-run/react"; + import { renderToString } from "react-dom/server"; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); + } + + export function handleError( + error: unknown, + { request }: { request: Request }, + ) { + console.error("App Specific Error Logging:"); + console.error(" Request: " + request.method + " " + request.url); + if (isRouteErrorResponse(error)) { + console.error(" Status: " + error.status + " " + error.statusText); + console.error(" Error: " + error.error.message); + console.error(" Stack: " + error.error.stack); + } else if (error instanceof Error) { + console.error(" Error: " + error.message); + console.error(" Stack: " + error.stack); + } else { + console.error("Dunno what this is"); + } + } + `, + ...routeFiles, + }, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); + expect(errorLogs[2][0]).toEqual(" Error: Render Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch('{"message":"Unexpected Server Error"}'); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).toMatch("x.stack=undefined;"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/_index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/_index"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/?loader=&_data=routes%2F_index" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let response = await fixture.requestData( + "/defer?loader", + "routes/defer" + ); + let text = await response.text(); + expect(text).toBe( + '{"lazy":"__deferred_promise:lazy"}\n\n' + + 'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n' + ); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestData( + "/resource?loader", + "routes/resource" + ); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/resource?loader=&_data=routes%2Fresource" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("sanitizes mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/?_data=not-a-route" + ); + expect(errorLogs[2][0]).toEqual(" Status: 403 Forbidden"); + expect(errorLogs[3][0]).toEqual( + ' Error: Route "not-a-route" does not match URL "/"' + ); + expect(errorLogs[4][0]).toMatch(" at "); + expect(errorLogs.length).toBe(5); + }); + }); + }); +}); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 9f33e7411d0..a35e9d192b3 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -527,3 +527,539 @@ test.describe("fetcher aborts and adjacent forms", () => { await page.waitForSelector("#idle", { timeout: 2000 }); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("useFetcher", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let CHEESESTEAK = "CHEESESTEAK"; + let LUNCH = "LUNCH"; + let PARENT_LAYOUT_LOADER = "parent layout loader"; + let PARENT_LAYOUT_ACTION = "parent layout action"; + let PARENT_INDEX_LOADER = "parent index loader"; + let PARENT_INDEX_ACTION = "parent index action"; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/resource-route-action-only.ts": js` + import { json } from "@remix-run/node"; + export function action() { + return json("${CHEESESTEAK}"); + } + `, + + "app/routes/fetcher-action-only-call.tsx": js` + import { useFetcher } from "@remix-run/react"; + + export default function FetcherActionOnlyCall() { + let fetcher = useFetcher(); + + let executeFetcher = () => { + fetcher.submit(new URLSearchParams(), { + method: 'post', + action: '/resource-route-action-only', + }); + }; + + return ( + <> + + {fetcher.data &&

{fetcher.data}
} + + ); + } + `, + + "app/routes/resource-route.tsx": js` + export function loader() { + return "${LUNCH}"; + } + export function action() { + return "${CHEESESTEAK}"; + } + `, + + "app/routes/_index.tsx": js` + import { useFetcher } from "@remix-run/react"; + export default function Index() { + let fetcher = useFetcher(); + return ( + <> + + + + + + +
{fetcher.data}
+ + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet } from "@remix-run/react"; + + export function action() { + return "${PARENT_LAYOUT_ACTION}"; + }; + + export function loader() { + return "${PARENT_LAYOUT_LOADER}"; + }; + + export default function Parent() { + return ; + } + `, + + "app/routes/parent._index.tsx": js` + import { useFetcher } from "@remix-run/react"; + + export function action() { + return "${PARENT_INDEX_ACTION}"; + }; + + export function loader() { + return "${PARENT_INDEX_LOADER}"; + }; + + export default function ParentIndex() { + let fetcher = useFetcher(); + + return ( + <> +
{fetcher.data}
+ + + + + + + + + ); + } + `, + + "app/routes/fetcher-echo.tsx": js` + import { json } from "@remix-run/node"; + import { useFetcher } from "@remix-run/react"; + + export async function action({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let contentType = request.headers.get('Content-Type'); + let value; + if (contentType.includes('application/json')) { + let json = await request.json(); + value = json === null ? json : json.value; + } else if (contentType.includes('text/plain')) { + value = await request.text(); + } else { + value = (await request.formData()).get('value'); + } + return json({ data: "ACTION (" + contentType + ") " + value }) + } + + export async function loader({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let value = new URL(request.url).searchParams.get('value'); + return json({ data: "LOADER " + value }) + } + + export default function Index() { + let fetcherValues = []; + if (typeof window !== 'undefined') { + if (!window.fetcherValues) { + window.fetcherValues = []; + } + fetcherValues = window.fetcherValues + } + + let fetcher = useFetcher(); + + let currentValue = fetcher.state + '/' + fetcher.data?.data; + if (fetcherValues[fetcherValues.length - 1] !== currentValue) { + fetcherValues.push(currentValue) + } + + return ( + <> + + + + + + + + + {fetcher.state === 'idle' ?

IDLE

: null} +
{JSON.stringify(fetcherValues)}
+ + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("No JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("Form can hit a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await Promise.all([ + page.waitForNavigation(), + app.clickSubmitButton("/resource-route", { + wait: false, + method: "get", + }), + ]); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a
 but Edge puts it in some weird code editor markup:
+        // 
+        //   
+        expect(await app.getHtml()).toContain(LUNCH);
+      });
+
+      test("Form can hit an action", async ({ page }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/");
+        await Promise.all([
+          page.waitForNavigation({ waitUntil: "load" }),
+          app.clickSubmitButton("/resource-route", {
+            wait: false,
+            method: "post",
+          }),
+        ]);
+        // Check full HTML here - Chromium/Firefox/Webkit seem to render this in
+        // a 
 but Edge puts it in some weird code editor markup:
+        // 
+        //   
+        expect(await app.getHtml()).toContain(CHEESESTEAK);
+      });
+    });
+
+    test("load can hit a loader", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/");
+      await app.clickElement("#fetcher-load");
+      await page.waitForSelector(`pre:has-text("${LUNCH}")`);
+    });
+
+    test("submit can hit an action", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+    });
+
+    test("submit can hit an action with json", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await page.fill("#fetcher-input", "input value");
+      await app.clickElement("#fetcher-submit-json");
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch(
+        'ACTION (application/json) input value"'
+      );
+    });
+
+    test("submit can hit an action with null json", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await app.clickElement("#fetcher-submit-json-null");
+      await new Promise((r) => setTimeout(r, 1000));
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch('ACTION (application/json) null"');
+    });
+
+    test("submit can hit an action with text", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await page.fill("#fetcher-input", "input value");
+      await app.clickElement("#fetcher-submit-text");
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch(
+        'ACTION (text/plain;charset=UTF-8) input value"'
+      );
+    });
+
+    test("submit can hit an action with empty text", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await app.clickElement("#fetcher-submit-text-empty");
+      await new Promise((r) => setTimeout(r, 1000));
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch(
+        'ACTION (text/plain;charset=UTF-8) "'
+      );
+    });
+
+    test("submit can hit an action only route", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-action-only-call");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+    });
+
+    test("fetchers handle ?index param correctly", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/parent");
+
+      await app.clickElement("#load-parent");
+      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+      await app.clickElement("#load-index");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+      // fetcher.submit({}) defaults to GET for the current Route
+      await app.clickElement("#submit-empty");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+      await app.clickElement("#submit-parent-get");
+      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+      await app.clickElement("#submit-index-get");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+      await app.clickElement("#submit-parent-post");
+      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_ACTION}")`);
+
+      await app.clickElement("#submit-index-post");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_ACTION}")`);
+    });
+
+    test("fetcher.load persists data through reloads", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+
+      await app.goto("/fetcher-echo", true);
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify(["idle/undefined"])
+      );
+
+      await page.fill("#fetcher-input", "1");
+      await app.clickElement("#fetcher-load");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"])
+      );
+
+      await page.fill("#fetcher-input", "2");
+      await app.clickElement("#fetcher-load");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify([
+          "idle/undefined",
+          "loading/undefined",
+          "idle/LOADER 1",
+          "loading/LOADER 1", // Preserves old data during reload
+          "idle/LOADER 2",
+        ])
+      );
+    });
+
+    test("fetcher.submit persists data through resubmissions", async ({
+      page,
+    }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+
+      await app.goto("/fetcher-echo", true);
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify(["idle/undefined"])
+      );
+
+      await page.fill("#fetcher-input", "1");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify([
+          "idle/undefined",
+          "submitting/undefined",
+          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        ])
+      );
+
+      await page.fill("#fetcher-input", "2");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify([
+          "idle/undefined",
+          "submitting/undefined",
+          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          // Preserves old data during resubmissions
+          "submitting/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+        ])
+      );
+    });
+  });
+
+  test.describe("fetcher aborts and adjacent forms", () => {
+    let fixture: Fixture;
+    let appFixture: AppFixture;
+
+    test.beforeAll(async () => {
+      fixture = await createFixture({
+        config: {
+          future: {
+            unstable_singleFetch: true,
+          },
+        },
+        files: {
+          "app/routes/_index.tsx": js`
+            import * as React from "react";
+            import {
+              Form,
+              useFetcher,
+              useLoaderData,
+              useNavigation
+            } from "@remix-run/react";
+
+            export async function loader({ request }) {
+              // 1 second timeout on data
+              await new Promise((r) => setTimeout(r, 1000));
+              return { foo: 'bar' };
+            }
+
+            export default function Index() {
+              const [open, setOpen] = React.useState(true);
+              const { data } = useLoaderData();
+              const navigation = useNavigation();
+
+              return (
+                
+ {navigation.state === 'idle' &&
Idle
} +
+ +
+ + + {open && setOpen(false)} />} +
+ ); + } + + function Child({ onClose }) { + const fetcher = useFetcher(); + + return ( + + + + + ); + } + `, + + "app/routes/api.tsx": js` + export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)); + return { message: 'Hello world!' } + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("Unmounting a fetcher does not cancel the request of an adjacent form", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Works as expected before the fetcher is loaded + + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for our navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + + // Breaks after the fetcher is loaded + + // re-mount the fetcher form + await app.clickElement("#open"); + // submit the fetcher form + await app.clickElement("#submit-fetcher"); + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + }); + }); +}); diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts index 70e2c47036c..ef3046bc0d6 100644 --- a/integration/helpers/playwright-fixture.ts +++ b/integration/helpers/playwright-fixture.ts @@ -156,7 +156,16 @@ export class PlaywrightFixture { * were called (or not). */ collectDataResponses() { - return collectDataResponses(this.page); + return this.collectResponses((url) => url.searchParams.has("_data")); + } + + /** + * Collects single fetch data responses from the network, usually after a + * link click or form submission. This is useful for asserting that specific + * loaders were called (or not). + */ + collectSingleFetchResponses() { + return this.collectResponses((url) => url.pathname.endsWith(".data")); } /** @@ -164,8 +173,16 @@ export class PlaywrightFixture { * form submission. A filter can be provided to only collect responses * that meet a certain criteria. */ - collectResponses(filter?: UrlFilter) { - return collectResponses(this.page, filter); + collectResponses(filter?: (url: URL) => boolean) { + let responses: Response[] = []; + + this.page.on("response", (res) => { + if (!filter || filter(new URL(res.url()))) { + responses.push(res); + } + }); + + return responses; } /** @@ -328,21 +345,3 @@ async function doAndWait( return result; } - -type UrlFilter = (url: URL) => boolean; - -function collectResponses(page: Page, filter?: UrlFilter): Response[] { - let responses: Response[] = []; - - page.on("response", (res) => { - if (!filter || filter(new URL(res.url()))) { - responses.push(res); - } - }); - - return responses; -} - -function collectDataResponses(page: Page) { - return collectResponses(page, (url) => url.searchParams.has("_data")); -} diff --git a/integration/loader-test.ts b/integration/loader-test.ts index c81201e8404..2604aa217a3 100644 --- a/integration/loader-test.ts +++ b/integration/loader-test.ts @@ -137,3 +137,147 @@ test.describe("loader in an app", () => { expect((await res.json()).message).toBe(FETCH_TARGET_TEXT); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("loader", () => { + let fixture: Fixture; + + let ROOT_DATA = "ROOT_DATA"; + let INDEX_DATA = "INDEX_DATA"; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export const loader = () => json("${ROOT_DATA}"); + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return "${INDEX_DATA}" + } + + export default function Index() { + return
+ } + `, + }, + }); + }); + + test("returns responses for a specific route", async () => { + let [root, index] = await Promise.all([ + fixture.requestData("/", "root"), + fixture.requestData("/", "routes/_index"), + ]); + + expect(root.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8" + ); + + expect(await root.json()).toBe(ROOT_DATA); + expect(await index.json()).toBe(INDEX_DATA); + }); + }); + + test.describe("loader in an app", () => { + let appFixture: AppFixture; + + let HOME_PAGE_TEXT = "hello world"; + let REDIRECT_TARGET_TEXT = "redirect target"; + let FETCH_TARGET_TEXT = "fetch target"; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Outlet } from '@remix-run/react' + + export default function Root() { + return ( + + + ${HOME_PAGE_TEXT} + + + + ); + } + `, + "app/routes/redirect.tsx": js` + import { redirect } from "@remix-run/node"; + export const loader = () => redirect("/redirect-target"); + export default () =>
Yo
+ `, + "app/routes/redirect-target.tsx": js` + export default () =>
${REDIRECT_TARGET_TEXT}
+ `, + "app/routes/fetch.tsx": js` + export function loader({ request }) { + return fetch(new URL(request.url).origin + '/fetch-target'); + } + `, + + "app/routes/fetch-target.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return json({ message: "${FETCH_TARGET_TEXT}" }) + } + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("sends a redirect", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + expect(await app.getHtml()).toMatch(HOME_PAGE_TEXT); + expect(await app.getHtml()).toMatch(REDIRECT_TARGET_TEXT); + }); + + test("handles raw fetch responses", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto(`/fetch`); + expect((await res.json()).message).toBe(FETCH_TARGET_TEXT); + }); + }); +}); From 38ff342d5fccced4ef1e2a58ce40cc9c652104ac Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 29 Feb 2024 12:23:50 -0500 Subject: [PATCH 28/57] Fix exports test --- packages/remix-react/__tests__/exports-test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/remix-react/__tests__/exports-test.tsx b/packages/remix-react/__tests__/exports-test.tsx index ae912a87fb5..99165a79bcd 100644 --- a/packages/remix-react/__tests__/exports-test.tsx +++ b/packages/remix-react/__tests__/exports-test.tsx @@ -14,9 +14,12 @@ let nonReExportedKeys = new Set([ "createHashRouter", "createMemoryRouter", // Don't re-export unsafe APIs + "unstable_DecodedResponse", "unstable_HistoryRouter", + "unstable_isDecodedResponse", "UNSAFE_DataRouterContext", "UNSAFE_DataRouterStateContext", + "UNSAFE_ErrorResponseImpl", "UNSAFE_FetchersContext", "UNSAFE_LocationContext", "UNSAFE_NavigationContext", From 36703787f68951f755e3ae9140209b2669c5fff6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 29 Feb 2024 12:26:39 -0500 Subject: [PATCH 29/57] Fix lint issue --- packages/remix-react/single-fetch.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index c5b9dcb64c9..1def921a873 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -9,7 +9,6 @@ import { decode } from "turbo-stream"; import { createRequestInit } from "./data"; import type { AssetsManifest } from "./entry"; -import invariant from "./invariant"; import type { RouteModules } from "./routeModules"; // IMPORTANT! Keep in sync with the types in @remix-run/server-runtime From 729336c4af976ebba909ff507e611b84f647d8dd Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 1 Mar 2024 15:20:10 -0500 Subject: [PATCH 30/57] Switch from DecodedResponse to HandlerResult --- integration/client-data-test.ts | 2 - .../remix-react/__tests__/exports-test.tsx | 2 - packages/remix-react/routes.tsx | 20 ++----- packages/remix-react/single-fetch.ts | 58 +++++++++++-------- packages/remix-server-runtime/server.ts | 21 +++++-- 5 files changed, 54 insertions(+), 49 deletions(-) diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 3205f83f5ac..4823e2abafa 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -2224,9 +2224,7 @@ test.describe("single fetch", () => { childClientLoaderHydrate: false, childAdditions: js` export async function clientAction({ serverAction }) { - debugger; let data = await serverAction(); - debugger; return { message: data.message + " (mutated by client)" } diff --git a/packages/remix-react/__tests__/exports-test.tsx b/packages/remix-react/__tests__/exports-test.tsx index 99165a79bcd..9144288dad7 100644 --- a/packages/remix-react/__tests__/exports-test.tsx +++ b/packages/remix-react/__tests__/exports-test.tsx @@ -14,9 +14,7 @@ let nonReExportedKeys = new Set([ "createHashRouter", "createMemoryRouter", // Don't re-export unsafe APIs - "unstable_DecodedResponse", "unstable_HistoryRouter", - "unstable_isDecodedResponse", "UNSAFE_DataRouterContext", "UNSAFE_DataRouterStateContext", "UNSAFE_ErrorResponseImpl", diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 9f7d19a0f67..17c15de1abf 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -1,9 +1,6 @@ import * as React from "react"; import type { HydrationState } from "@remix-run/router"; -import { - UNSAFE_ErrorResponseImpl as ErrorResponse, - unstable_isDecodedResponse as isDecodedResponse, -} from "@remix-run/router"; +import { UNSAFE_ErrorResponseImpl as ErrorResponse } from "@remix-run/router"; import type { ActionFunctionArgs, LoaderFunctionArgs, @@ -262,29 +259,22 @@ export function createClientRoutes( ) { if (typeof singleFetch === "function") { let result = await singleFetch(); - if (unwrap && isDecodedResponse(result)) { - return result.data; - } return result; } - let result = await fetchServerHandler(request, route); - if (unwrap) { - return unwrapServerResponse(result); - } - return result; + return unwrap ? unwrapServerResponse(result) : result; } - async function fetchServerLoader( + function fetchServerLoader( request: Request, unwrap: boolean, singleFetch: unknown ) { - if (!route.hasLoader) return null; + if (!route.hasLoader) return Promise.resolve(null); return fetchServerHandlerAndMaybeUnwrap(request, unwrap, singleFetch); } - async function fetchServerAction( + function fetchServerAction( request: Request, unwrap: boolean, singleFetch: unknown diff --git a/packages/remix-react/single-fetch.ts b/packages/remix-react/single-fetch.ts index 1def921a873..4c05ae7e5f1 100644 --- a/packages/remix-react/single-fetch.ts +++ b/packages/remix-react/single-fetch.ts @@ -1,6 +1,10 @@ -import type { DataStrategyMatch, ErrorResponse } from "@remix-run/router"; +import type { + DataStrategyFunction, + DataStrategyMatch, + ErrorResponse, + unstable_HandlerResult as HandlerResult, +} from "@remix-run/router"; import { - unstable_DecodedResponse, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, redirect, } from "@remix-run/router"; @@ -13,7 +17,7 @@ import type { RouteModules } from "./routeModules"; // IMPORTANT! Keep in sync with the types in @remix-run/server-runtime type SingleFetchResult = - | { data: unknown; status?: number } // status only included in actions + | { data: unknown } | { error: unknown } | { redirect: string; status: number; revalidate: boolean; reload: boolean }; type SingleFetchResults = { @@ -23,17 +27,19 @@ type SingleFetchResults = { export function getSingleFetchDataStrategy( manifest: AssetsManifest, routeModules: RouteModules -) { +): DataStrategyFunction { return async ({ request, matches }: DataStrategyFunctionArgs) => { // This function is the way for a loader/action to "talk" to the server let singleFetch: (routeId: string) => Promise; + let actionStatus: number | undefined; if (request.method !== "GET") { // Actions are simple since they're singular - just hit the server singleFetch = async (routeId) => { let url = singleFetchUrl(request.url); let init = await createRequestInit(request); - let result = await fetchAndDecode(url, init); - return unwrapSingleFetchResult(result as SingleFetchResult, routeId); + let { data, status } = await fetchAndDecode(url, init); + actionStatus = status; + return unwrapSingleFetchResult(data as SingleFetchResult, routeId); }; } else { // Loaders are trickier since we only want to hit the server once, so we @@ -55,8 +61,8 @@ export function getSingleFetchDataStrategy( ) ); - let result = await fetchAndDecode(url); - return result as SingleFetchResults; + let { data } = await fetchAndDecode(url); + return data as SingleFetchResults; }; singleFetch = async (routeId) => { @@ -64,19 +70,31 @@ export function getSingleFetchDataStrategy( singleFetchPromise = makeSingleFetchCall(); } let results = await singleFetchPromise; - if (results[routeId] !== undefined) { - return unwrapSingleFetchResult(results[routeId], routeId); - } - return null; + return results[routeId] !== undefined + ? unwrapSingleFetchResult(results[routeId], routeId) + : null; }; } // Call the route handlers passing through the `singleFetch` function that will // be called instead of making a server call return Promise.all( - matches.map(async (m) => { - return m.resolve((handler) => handler(() => singleFetch(m.route.id))); - }) + matches.map(async (m) => + m.resolve(async (handler): Promise => { + try { + return { + type: "data", + result: await handler(() => singleFetch(m.route.id)), + status: actionStatus, + }; + } catch (e) { + return { + type: "error", + result: e, + }; + } + }) + ) ); }; } @@ -174,7 +192,7 @@ async function fetchAndDecode(url: URL, init?: RequestInit) { } }, ]); - return decoded.value; + return { status: res.status, data: decoded.value }; } // If we didn't get back a turbo-stream response, then we never reached the @@ -196,14 +214,6 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { } return redirect(result.redirect, { status: result.status, headers }); } else if ("data" in result) { - if (typeof result.status === "number") { - return new unstable_DecodedResponse( - result.status, - "", - new Headers(), - result.data - ); - } return result.data; } else { throw new Error(`No action response found for routeId "${routeId}"`); diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 6d60f660e6f..7fc61c2a28b 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -304,7 +304,7 @@ async function handleDataRequest( // IMPORTANT! Keep in sync with the types in @remix-run/react type SingleFetchResult = - | { data: unknown; status?: number } // status only included in actions + | { data: unknown } | { error: unknown } | { redirect: string; status: number; revalidate: boolean; reload: boolean }; type SingleFetchResults = { @@ -321,7 +321,7 @@ async function handleSingleFetchRequest( loadContext: AppLoadContext, handleError: (err: unknown) => void ): Promise { - let [result, headers] = + let [result, headers, actionStatus] = request.method !== "GET" ? await singleFetchAction( request, @@ -357,7 +357,10 @@ async function handleSingleFetchRequest( } }, ]), - { headers: resultHeaders } + { + status: actionStatus || 200, + headers: resultHeaders, + } ); } @@ -367,7 +370,7 @@ async function singleFetchAction( staticHandler: StaticHandler, loadContext: AppLoadContext, handleError: (err: unknown) => void -): Promise<[SingleFetchResult, Headers]> { +): Promise<[SingleFetchResult, Headers, number]> { try { let handlerRequest = new Request(handlerUrl, { method: request.method, @@ -393,11 +396,13 @@ async function singleFetchAction( reload: response.headers.has("X-Remix-Reload-Document"), }, response.headers, + 200, // Don't trigger a redirect on the `fetch` ]; } return [ - { data: await unwrapResponse(response), status: response.status }, + { data: await unwrapResponse(response) }, response.headers, + response.status, ]; } catch (err) { handleError(err); @@ -408,7 +413,11 @@ async function singleFetchAction( await unwrapResponse(err) ) : err; - return [{ error }, new Headers()]; + return [ + { error }, + new Headers(), + isRouteErrorResponse(error) ? error.status : 500, + ]; } } From 7a80a6b18e117e3a529d1497e70d2e2edda9dfaa Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 1 Mar 2024 15:22:41 -0500 Subject: [PATCH 31/57] bump RR experimental --- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 ++--- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +-- yarn.lock | 30 +++++++++++----------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 97f38c8e178..c10c4cfd1f9 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.8.0", - "@remix-run/router": "0.0.0-experimental-0141b5ec", + "@remix-run/router": "0.0.0-experimental-432fcb2e", "@remix-run/server-runtime": "2.8.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 456729aaf29..83914661550 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,10 +16,10 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-0141b5ec", + "@remix-run/router": "0.0.0-experimental-432fcb2e", "@remix-run/server-runtime": "2.8.0", - "react-router": "0.0.0-experimental-0141b5ec", - "react-router-dom": "0.0.0-experimental-0141b5ec", + "react-router": "0.0.0-experimental-432fcb2e", + "react-router-dom": "0.0.0-experimental-432fcb2e", "turbo-stream": "^1.2.1" }, "devDependencies": { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index e39978796ce..9cd7b7417ba 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "0.0.0-experimental-0141b5ec", + "@remix-run/router": "0.0.0-experimental-432fcb2e", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index 2b1a7cc43ad..5c18b31eece 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.8.0", "@remix-run/react": "2.8.0", - "@remix-run/router": "0.0.0-experimental-0141b5ec", - "react-router-dom": "0.0.0-experimental-0141b5ec" + "@remix-run/router": "0.0.0-experimental-432fcb2e", + "react-router-dom": "0.0.0-experimental-432fcb2e" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 097d36ada02..d76a4c8159f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,10 +2482,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@0.0.0-experimental-0141b5ec": - version "0.0.0-experimental-0141b5ec" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-0141b5ec.tgz#27bfb0967bae832056ad957816bcad0a8f33f80d" - integrity sha512-EOJjGBZAfqDs9PuvnMiJUDQDaWJvwuUJ9fIM7g2lPrP9OMg8xFjSFA1wownDgbxvU4zhgnNg54UMkKtMg60MUA== +"@remix-run/router@0.0.0-experimental-432fcb2e": + version "0.0.0-experimental-432fcb2e" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-0.0.0-experimental-432fcb2e.tgz#b26cba7f48d6b8f2336ee4253219a5e52cdc4ade" + integrity sha512-UFnDA4Oj1XDMapjIu9z/LZr4/SGYPLd77GznRnTVIcn1/+L3gh26z4sJU0ia8cBWr0DE4epLqMt/ZGo8t+8K1w== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -11300,20 +11300,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@0.0.0-experimental-0141b5ec: - version "0.0.0-experimental-0141b5ec" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-0141b5ec.tgz#4c663d9f5dabd627a691a768608ce25565ee8fc1" - integrity sha512-Rn7sCPl93aXRIdHR1LbSRBHBLsmAhldjIfgv2OU+9FiZ9kA/0BJrl/cGB+3hocwquXPT+8GXekC2X0TZAdbMjg== +react-router-dom@0.0.0-experimental-432fcb2e: + version "0.0.0-experimental-432fcb2e" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-0.0.0-experimental-432fcb2e.tgz#958c43b3feeb706ac26cd126a6f3adef50b42dd4" + integrity sha512-E9vDNWbrU8FN8AacZHDrT/jmxe9tNZvYPdWHR2474DhgMRlTaXmUdDJpsxa49ZnSyWrV3DAfIm6Fq0QLqfLMbQ== dependencies: - "@remix-run/router" "0.0.0-experimental-0141b5ec" - react-router "0.0.0-experimental-0141b5ec" + "@remix-run/router" "0.0.0-experimental-432fcb2e" + react-router "0.0.0-experimental-432fcb2e" -react-router@0.0.0-experimental-0141b5ec: - version "0.0.0-experimental-0141b5ec" - resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-0141b5ec.tgz#bcd779624aba333591e5891c8b544e5fc7967860" - integrity sha512-TNcVbgxTbtXfrBPH6MbEK40+HmUH2vH+nf0vKVhDFAAVYoGJpksxaONx8yzjOZHriI0hCXfv0UR+EKf+v7sImQ== +react-router@0.0.0-experimental-432fcb2e: + version "0.0.0-experimental-432fcb2e" + resolved "https://registry.npmjs.org/react-router/-/react-router-0.0.0-experimental-432fcb2e.tgz#6918f6b036152b2d09ffac4e6c624584756a12aa" + integrity sha512-wlB7J+i80XkbJBeqNz+AnsL/Yf7zy+a4ZsjuVrhaYsp3SHSbmdtn8VTnJkbQfMVEv8A/SVtB0lA37eIXGai72A== dependencies: - "@remix-run/router" "0.0.0-experimental-0141b5ec" + "@remix-run/router" "0.0.0-experimental-432fcb2e" react@^18.2.0: version "18.2.0" From c6750a45fa8a9d23512b5b79e704d0193d71247e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 1 Mar 2024 16:17:22 -0500 Subject: [PATCH 32/57] Leverage turnbo-stream for document streaming when single fetch is enabled --- packages/remix-react/browser.tsx | 41 +++++- packages/remix-react/components.tsx | 28 +++- packages/remix-react/entry.ts | 14 ++ packages/remix-react/server.tsx | 137 +++++++++++++++--- packages/remix-server-runtime/data.ts | 14 ++ packages/remix-server-runtime/entry.ts | 2 + packages/remix-server-runtime/routes.ts | 2 + packages/remix-server-runtime/server.ts | 42 ++++-- .../remix-server-runtime/serverHandoff.ts | 2 +- 9 files changed, 245 insertions(+), 37 deletions(-) diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index d232f6d7e41..23650351c83 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -4,9 +4,10 @@ import type { ReactElement } from "react"; import * as React from "react"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; import { matchRoutes, RouterProvider } from "react-router-dom"; +import { decode } from "turbo-stream"; import { RemixContext } from "./components"; -import type { EntryContext, FutureConfig } from "./entry"; +import type { AssetsManifest, FutureConfig } from "./entry"; import { RemixErrorBoundary } from "./errorBoundaries"; import { deserializeErrors } from "./errors"; import type { RouteModules } from "./routeModules"; @@ -16,6 +17,7 @@ import { shouldHydrateRouteLoader, } from "./routes"; import { getSingleFetchDataStrategy } from "./single-fetch"; +import invariant from "./invariant"; /* eslint-disable prefer-let/prefer-let */ declare global { @@ -26,6 +28,8 @@ declare global { criticalCss?: string; future: FutureConfig; isSpaMode: boolean; + stream: ReadableStream | undefined; + streamController: ReadableStreamDefaultController; // The number of active deferred keys rendered on the server a?: number; dev?: { @@ -35,7 +39,7 @@ declare global { }; var __remixRouter: Router; var __remixRouteModules: RouteModules; - var __remixManifest: EntryContext["manifest"]; + var __remixManifest: AssetsManifest; var __remixRevalidation: number | undefined; var __remixClearCriticalCss: (() => void) | undefined; var $RefreshRuntime$: { @@ -46,6 +50,12 @@ declare global { export interface RemixBrowserProps {} +let stateDecodingPromise: + | (Promise & { + value?: unknown; + error?: unknown; + }) + | undefined; let router: Router; let routerInitialized = false; let hmrAbortController: AbortController | undefined; @@ -72,7 +82,7 @@ if (import.meta && import.meta.hot) { assetsManifest, needsRevalidation, }: { - assetsManifest: EntryContext["manifest"]; + assetsManifest: AssetsManifest; needsRevalidation: Set; }) => { let router = await hmrRouterReadyPromise; @@ -204,6 +214,31 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { return <>; } + // When single fetch is enabled, we need to suspend until the initial state + // snapshot is decoded into window.__remixContext.state + if (window.__remixContext.future.unstable_singleFetch) { + if (!stateDecodingPromise) { + let stream = window.__remixContext.stream; + invariant(stream, "No stream found for single fetch decoding"); + window.__remixContext.stream = undefined; + stateDecodingPromise = decode(stream) + .then((value) => { + window.__remixContext.state = + value.value as typeof window.__remixContext.state; + stateDecodingPromise!.value = true; + }) + .catch((e) => { + stateDecodingPromise!.error = e; + }); + } + if (stateDecodingPromise.error) { + throw stateDecodingPromise.error; + } + if (!stateDecodingPromise.value) { + throw stateDecodingPromise; + } + } + let routes = createClientRoutes( window.__remixManifest.routes, window.__remixRouteModules, diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index a8252d28461..75e67123e4f 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -622,12 +622,25 @@ export type ScriptProps = Omit< * @see https://remix.run/components/scripts */ export function Scripts(props: ScriptProps) { - let { manifest, serverHandoffString, abortDelay, serializeError, isSpaMode } = - useRemixContext(); + let { + manifest, + serverHandoffString, + abortDelay, + serializeError, + isSpaMode, + future, + renderMeta, + } = useRemixContext(); let { router, static: isStatic, staticContext } = useDataRouterContext(); let { matches: routerMatches } = useDataRouterStateContext(); let navigation = useNavigation(); + // Let know that we hydrated and we should render the single + // fetch streaming scripts + if (renderMeta) { + renderMeta.didRenderScripts = true; + } + let matches = getActiveMatches(routerMatches, null, isSpaMode); React.useEffect(() => { @@ -688,8 +701,17 @@ export function Scripts(props: ScriptProps) { let deferredScripts: any[] = []; let initialScripts = React.useMemo(() => { + let streamScript = future.unstable_singleFetch + ? // prettier-ignore + "window.__remixContext.stream = new ReadableStream({" + + "start(controller){" + + "window.__remixContext.streamController = controller;" + + "}" + + "}).pipeThrough(new TextEncoderStream());" + : ""; + let contextScript = staticContext - ? `window.__remixContext = ${serverHandoffString};` + ? `window.__remixContext = ${serverHandoffString};${streamScript}` : " "; let activeDeferreds = staticContext?.activeDeferreds; diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index 9360ee42cf1..1a3477d67a1 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -18,12 +18,26 @@ export interface RemixContextObject { isSpaMode: boolean; abortDelay?: number; serializeError?(error: Error): SerializedError; + renderMeta?: { + didRenderScripts: boolean; + streamCache: Record< + number, + Promise & { + result?: { + done: boolean; + value: string; + }; + error?: unknown; + } + >; + }; } // Additional React-Router information needed at runtime, but not hydrated // through RemixContext export interface EntryContext extends RemixContextObject { staticHandlerContext: StaticHandlerContext; + serverHandoffStream?: ReadableStream; } export interface FutureConfig { diff --git a/packages/remix-react/server.tsx b/packages/remix-react/server.tsx index 08630ec5bed..cf25a474340 100644 --- a/packages/remix-react/server.tsx +++ b/packages/remix-react/server.tsx @@ -71,25 +71,124 @@ export function RemixServer({ }); return ( - + + + + + + {context.future.unstable_singleFetch && context.serverHandoffStream ? ( + + + + ) : null} + + ); +} + +export interface StreamTransferProps { + context: EntryContext; + identifier: number; + reader: ReadableStreamDefaultReader; + textDecoder: TextDecoder; +} + +// StreamTransfer recursively renders down chunks of the `serverHandoffStream` +// into the client-side `streamController` +function StreamTransfer({ + context, + identifier, + reader, + textDecoder, +}: StreamTransferProps) { + // If the user didn't render the component then we don't have to + // bother streaming anything in + if (!context.renderMeta?.didRenderScripts) { + return null; + } + + if (!context.renderMeta.streamCache) { + context.renderMeta.streamCache = {}; + } + let { streamCache } = context.renderMeta; + let promise = streamCache[identifier]; + if (!promise) { + promise = streamCache[identifier] = reader + .read() + .then((result) => { + streamCache[identifier].result = { + done: result.done, + value: textDecoder.decode(result.value, { stream: true }), + }; + }) + .catch((e) => { + streamCache[identifier].error = e; + }); + } + + if (promise.error) { + throw promise.error; + } + if (promise.result === undefined) { + throw promise; + } + + let { done, value } = promise.result; + let scriptTag = value ? ( +