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 (
+
+ );
+ }
+ `,
+
+ "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 (
+
+ );
+ }
+ `,
+
+ [`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 (
+
+ );
+ }
+ `,
+
+ [`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}
+
+
+
+
+ ${HAS_BOUNDARY_LOADER}
+
+
+ ${HAS_BOUNDARY_LOADER}/child
+
+
+ ${NO_BOUNDARY_LOADER}
+
+
+ )
+ }
+ `,
+
+ [`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 (
+
+ )
+ }
+ `,
+
+ "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}
+ revalidator.revalidate()}>Revalidate
+ >
+ );
+ }
+ 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}
+ revalidator.revalidate()}>Revalidate
+ >
+ );
+ }
+ 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 (
+
+
setCount((c) => c+1)}>Increment
+
{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 ? (
+
+ ) : 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 (
+
+
+
+
+
+
+
+
+
+
+ {/* 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 }>
+ (
+
+ )}
+ />
+
+ 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("