diff --git a/.eslintignore b/.eslintignore index fa3b682248..26b8a0ae29 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ node_modules/ pnpm-lock.yaml /docs/api examples/**/dist/ +/integration/helpers/vite-plugin-cloudflare-template/worker-configuration.d.ts /playground/ /playground-local/ packages/**/dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index facdff17d4..977b7a65ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,26 +13,29 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) + - [v7.5.1](#v751) + - [Patch Changes](#patch-changes) + - [Unstable Changes](#unstable-changes) - [v7.5.0](#v750) - [What's Changed](#whats-changed) - [`route.lazy` Object API](#routelazy-object-api) - [Minor Changes](#minor-changes) - - [Patch Changes](#patch-changes) - - [Unstable Changes](#unstable-changes) + - [Patch Changes](#patch-changes-1) + - [Unstable Changes](#unstable-changes-1) - [Changes by Package](#changes-by-package) - [v7.4.1](#v741) - [Security Notice](#security-notice) - - [Patch Changes](#patch-changes-1) - - [Unstable Changes](#unstable-changes-1) - - [v7.4.0](#v740) - - [Minor Changes](#minor-changes-1) - [Patch Changes](#patch-changes-2) - [Unstable Changes](#unstable-changes-2) + - [v7.4.0](#v740) + - [Minor Changes](#minor-changes-1) + - [Patch Changes](#patch-changes-3) + - [Unstable Changes](#unstable-changes-3) - [Changes by Package](#changes-by-package-1) - [v7.3.0](#v730) - [Minor Changes](#minor-changes-2) - - [Patch Changes](#patch-changes-3) - - [Unstable Changes](#unstable-changes-3) + - [Patch Changes](#patch-changes-4) + - [Unstable Changes](#unstable-changes-4) - [Client-side `context` (unstable)](#client-side-context-unstable) - [Middleware (unstable)](#middleware-unstable) - [Middleware `context` parameter](#middleware-context-parameter) @@ -44,28 +47,28 @@ We manage release notes in this file instead of the paginated Github Releases Pa - [Prerendering with a SPA Fallback](#prerendering-with-a-spa-fallback) - [Allow a root `loader` in SPA Mode](#allow-a-root-loader-in-spa-mode) - [Minor Changes](#minor-changes-3) - - [Patch Changes](#patch-changes-4) - - [Unstable Changes](#unstable-changes-4) + - [Patch Changes](#patch-changes-5) + - [Unstable Changes](#unstable-changes-5) - [Split Route Modules (unstable)](#split-route-modules-unstable) - [Changes by Package](#changes-by-package-3) - [v7.1.5](#v715) - - [Patch Changes](#patch-changes-5) - - [v7.1.4](#v714) - [Patch Changes](#patch-changes-6) - - [v7.1.3](#v713) + - [v7.1.4](#v714) - [Patch Changes](#patch-changes-7) - - [v7.1.2](#v712) + - [v7.1.3](#v713) - [Patch Changes](#patch-changes-8) - - [v7.1.1](#v711) + - [v7.1.2](#v712) - [Patch Changes](#patch-changes-9) + - [v7.1.1](#v711) + - [Patch Changes](#patch-changes-10) - [v7.1.0](#v710) - [Minor Changes](#minor-changes-4) - - [Patch Changes](#patch-changes-10) + - [Patch Changes](#patch-changes-11) - [Changes by Package](#changes-by-package-4) - [v7.0.2](#v702) - - [Patch Changes](#patch-changes-11) - - [v7.0.1](#v701) - [Patch Changes](#patch-changes-12) + - [v7.0.1](#v701) + - [Patch Changes](#patch-changes-13) - [v7.0.0](#v700) - [Breaking Changes](#breaking-changes) - [Package Restructuring](#package-restructuring) @@ -82,199 +85,199 @@ We manage release notes in this file instead of the paginated Github Releases Pa - [Major Changes (`react-router`)](#major-changes-react-router) - [Major Changes (`@react-router/*`)](#major-changes-react-router-1) - [Minor Changes](#minor-changes-5) - - [Patch Changes](#patch-changes-13) + - [Patch Changes](#patch-changes-14) - [Changes by Package](#changes-by-package-5) - [React Router v6 Releases](#react-router-v6-releases) - [v6.30.0](#v6300) - [Minor Changes](#minor-changes-6) - - [Patch Changes](#patch-changes-14) + - [Patch Changes](#patch-changes-15) - [v6.29.0](#v6290) - [Minor Changes](#minor-changes-7) - - [Patch Changes](#patch-changes-15) - - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-16) - - [v6.28.1](#v6281) + - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-17) + - [v6.28.1](#v6281) + - [Patch Changes](#patch-changes-18) - [v6.28.0](#v6280) - [What's Changed](#whats-changed-2) - [Minor Changes](#minor-changes-8) - - [Patch Changes](#patch-changes-18) + - [Patch Changes](#patch-changes-19) - [v6.27.0](#v6270) - [What's Changed](#whats-changed-3) - [Stabilized APIs](#stabilized-apis) - [Minor Changes](#minor-changes-9) - - [Patch Changes](#patch-changes-19) - - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-20) - - [v6.26.1](#v6261) + - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-21) + - [v6.26.1](#v6261) + - [Patch Changes](#patch-changes-22) - [v6.26.0](#v6260) - [Minor Changes](#minor-changes-10) - - [Patch Changes](#patch-changes-22) - - [v6.25.1](#v6251) - [Patch Changes](#patch-changes-23) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-24) - [v6.25.0](#v6250) - [What's Changed](#whats-changed-4) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - [Minor Changes](#minor-changes-11) - - [Patch Changes](#patch-changes-24) - - [v6.24.1](#v6241) - [Patch Changes](#patch-changes-25) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-26) - [v6.24.0](#v6240) - [What's Changed](#whats-changed-5) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-26) - - [v6.23.1](#v6231) - [Patch Changes](#patch-changes-27) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-28) - [v6.23.0](#v6230) - [What's Changed](#whats-changed-6) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - [Minor Changes](#minor-changes-13) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-28) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-29) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-30) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-31) - [v6.22.0](#v6220) - [What's Changed](#whats-changed-7) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-31) - - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-32) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-33) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-34) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-35) - [v6.21.0](#v6210) - [What's Changed](#whats-changed-8) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-35) - - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-36) + - [v6.20.1](#v6201) + - [Patch Changes](#patch-changes-37) - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-16) - - [Patch Changes](#patch-changes-37) + - [Patch Changes](#patch-changes-38) - [v6.19.0](#v6190) - [What's Changed](#whats-changed-9) - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-17) - - [Patch Changes](#patch-changes-38) + - [Patch Changes](#patch-changes-39) - [v6.18.0](#v6180) - [What's Changed](#whats-changed-10) - [New Fetcher APIs](#new-fetcher-apis) - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-18) - - [Patch Changes](#patch-changes-39) + - [Patch Changes](#patch-changes-40) - [v6.17.0](#v6170) - [What's Changed](#whats-changed-11) - [View Transitions 🚀](#view-transitions-) - [Minor Changes](#minor-changes-19) - - [Patch Changes](#patch-changes-40) + - [Patch Changes](#patch-changes-41) - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-20) - - [Patch Changes](#patch-changes-41) + - [Patch Changes](#patch-changes-42) - [v6.15.0](#v6150) - [Minor Changes](#minor-changes-21) - - [Patch Changes](#patch-changes-42) - - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-43) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-44) + - [v6.14.1](#v6141) + - [Patch Changes](#patch-changes-45) - [v6.14.0](#v6140) - [What's Changed](#whats-changed-12) - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-22) - - [Patch Changes](#patch-changes-45) + - [Patch Changes](#patch-changes-46) - [v6.13.0](#v6130) - [What's Changed](#whats-changed-13) - [`future.v7_startTransition`](#futurev7_starttransition) - [Minor Changes](#minor-changes-23) - - [Patch Changes](#patch-changes-46) - - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-47) + - [v6.12.1](#v6121) + - [Patch Changes](#patch-changes-48) - [v6.12.0](#v6120) - [What's Changed](#whats-changed-14) - [`React.startTransition` support](#reactstarttransition-support) - [Minor Changes](#minor-changes-24) - - [Patch Changes](#patch-changes-48) - - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-49) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-50) + - [v6.11.1](#v6111) + - [Patch Changes](#patch-changes-51) - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-25) - - [Patch Changes](#patch-changes-51) + - [Patch Changes](#patch-changes-52) - [v6.10.0](#v6100) - [What's Changed](#whats-changed-15) - [Minor Changes](#minor-changes-26) - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - - [Patch Changes](#patch-changes-52) + - [Patch Changes](#patch-changes-53) - [v6.9.0](#v690) - [What's Changed](#whats-changed-16) - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-27) - - [Patch Changes](#patch-changes-53) - - [v6.8.2](#v682) - [Patch Changes](#patch-changes-54) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-55) + - [v6.8.1](#v681) + - [Patch Changes](#patch-changes-56) - [v6.8.0](#v680) - [Minor Changes](#minor-changes-28) - - [Patch Changes](#patch-changes-56) + - [Patch Changes](#patch-changes-57) - [v6.7.0](#v670) - [Minor Changes](#minor-changes-29) - - [Patch Changes](#patch-changes-57) - - [v6.6.2](#v662) - [Patch Changes](#patch-changes-58) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-59) + - [v6.6.1](#v661) + - [Patch Changes](#patch-changes-60) - [v6.6.0](#v660) - [What's Changed](#whats-changed-17) - [Minor Changes](#minor-changes-30) - - [Patch Changes](#patch-changes-60) + - [Patch Changes](#patch-changes-61) - [v6.5.0](#v650) - [What's Changed](#whats-changed-18) - [Minor Changes](#minor-changes-31) - - [Patch Changes](#patch-changes-61) - - [v6.4.5](#v645) - [Patch Changes](#patch-changes-62) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-63) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-64) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-65) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-66) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-67) - [v6.4.0](#v640) - [What's Changed](#whats-changed-19) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-67) + - [Patch Changes](#patch-changes-68) - [v6.3.0](#v630) - [Minor Changes](#minor-changes-32) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-68) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-69) + - [v6.2.1](#v621) + - [Patch Changes](#patch-changes-70) - [v6.2.0](#v620) - [Minor Changes](#minor-changes-33) - - [Patch Changes](#patch-changes-70) - - [v6.1.1](#v611) - [Patch Changes](#patch-changes-71) + - [v6.1.1](#v611) + - [Patch Changes](#patch-changes-72) - [v6.1.0](#v610) - [Minor Changes](#minor-changes-34) - - [Patch Changes](#patch-changes-72) - - [v6.0.2](#v602) - [Patch Changes](#patch-changes-73) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-74) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-75) - [v6.0.0](#v600) @@ -316,6 +319,50 @@ Date: YYYY-MM-DD **Full Changelog**: [`v7.X.Y...v7.X.Y`](https://github.com/remix-run/react-router/compare/react-router@7.X.Y...react-router@7.X.Y) --> +## v7.5.1 + +Date: 2025-04-17 + +### Patch Changes + +- `react-router` - When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration ([#13376](https://github.com/remix-run/react-router/pull/13376)) + + - If you move the code for these properties into a separate file, since the hydrate properties were unused already (if the route wasn't present during hydration), you can avoid downloading them at all. For example: + + ```ts + createBrowserRouter([ + { + path: "/show/:showId", + lazy: { + loader: async () => (await import("./show.loader.js")).loader, + Component: async () => + (await import("./show.component.js")).Component, + HydrateFallback: async () => + (await import("./show.hydrate-fallback.js")).HydrateFallback, + }, + }, + ]); + ``` + +- `react-router` - Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route ([#13253](https://github.com/remix-run/react-router/pull/13253)) +- `react-router` - Properly revalidate pre-rendered paths when param values change when using `ssr:false` + `prerender` configs ([#13380](https://github.com/remix-run/react-router/pull/13380)) +- `react-router` - Fix pre-rendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) +- `react-router` - Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders ([#13223](https://github.com/remix-run/react-router/pull/13223)) + - This was a Remix v2 implementation detail inadvertently left in for React Router v7 + - Now that we allow returning `undefined` from loaders, our prior check of `loaderData[routeId] !== undefined` was no longer sufficient and was changed to a `routeId in loaderData` check - these `null` values can cause issues for this new check + - ⚠️ This could be a "breaking bug fix" for you if you are doing manual SSR with `createStaticHandler()`/``, and using `context.loaderData` to control `` hydration behavior on the client + +### Unstable Changes + +⚠️ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- `react-router` - Add better error messaging when `getLoadContext` is not updated to return a `Map` ([#13242](https://github.com/remix-run/react-router/pull/13242)) +- `react-router` - Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled ([#13381](https://github.com/remix-run/react-router/pull/13381)) +- `react-router` - Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations ([#13395](https://github.com/remix-run/react-router/pull/13395)) +- `react-router` - Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` ([#13253](https://github.com/remix-run/react-router/pull/13253)) + +**Full Changelog**: [`v7.5.0...v7.5.1`](https://github.com/remix-run/react-router/compare/react-router@7.5.0...react-router@7.5.1) + ## v7.5.0 Date: 2025-04-04 diff --git a/integration/action-test.ts b/integration/action-test.ts index 7bb077c18c..c08e6d4bdb 100644 --- a/integration/action-test.ts +++ b/integration/action-test.ts @@ -74,8 +74,7 @@ test.describe("actions", () => { `, [`app/routes/${THROWS_REDIRECT}.jsx`]: js` - import { redirect } from "react-router"; - import { Form } from "react-router"; + import { redirect, Form } from "react-router"; export function action() { throw redirect("/${REDIRECT_TARGET}") diff --git a/integration/blocking-test.ts b/integration/blocking-test.ts index 5b99df29b8..1929f02b48 100644 --- a/integration/blocking-test.ts +++ b/integration/blocking-test.ts @@ -40,7 +40,7 @@ test("handles synchronous proceeding correctly", async ({ page }) => { `, "app/routes/b.tsx": js` import * as React from "react"; - import { Form, useAction, useBlocker } from "react-router"; + import { Form, useBlocker } from "react-router"; export default function Component() { return (
diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 611ca5bf41..e0006135ae 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -335,7 +335,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router" import { Await, useLoaderData } from "react-router" export function loader() { return { @@ -685,7 +684,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router"; import { useLoaderData, useRevalidator } from "react-router"; let isFirstCall = true; export async function loader({ serverLoader }) { @@ -763,7 +761,7 @@ test.describe("Client Data", () => { childClientLoaderHydrate: false, }), "app/routes/parent.child.tsx": js` - import { ClientLoaderFunctionArgs, useRouteError } from "react-router"; + import { useRouteError } from "react-router"; export function loader() { throw new Error("Broken!") @@ -1286,7 +1284,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router"; import { Form, useRouteError } from "react-router"; export async function clientAction({ serverAction }) { return await serverAction(); @@ -1508,7 +1505,6 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { json } from "react-router"; import { Form, useRouteError } from "react-router"; export async function clientAction({ serverAction }) { return await serverAction(); diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts index 0ec725d5fa..42f2312ddb 100644 --- a/integration/error-boundary-test.ts +++ b/integration/error-boundary-test.ts @@ -971,7 +971,6 @@ test("Allows back-button out of an error boundary after a hard reload", async ({ `, "app/routes/boom.tsx": js` - import { json } from "react-router"; export function loader() { return boom(); } export default function() { return my page; } `, diff --git a/integration/fetch-globals-test.ts b/integration/fetch-globals-test.ts index f7a5c3fe42..2d07e9ada6 100644 --- a/integration/fetch-globals-test.ts +++ b/integration/fetch-globals-test.ts @@ -14,7 +14,6 @@ test.beforeAll(async () => { fixture = await createFixture({ files: { "app/routes/_index.tsx": js` - import { json } from "react-router"; import { useLoaderData } from "react-router"; export async function loader() { const resp = await fetch('http://github.com/service/https://reqres.in/api/users?page=2'); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 138300cb25..36472a25cf 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -23,7 +23,6 @@ test.describe("useFetcher", () => { fixture = await createFixture({ files: { "app/routes/resource-route-action-only.ts": js` - import { json } from "react-router"; export function action() { return new Response("${CHEESESTEAK}"); } diff --git a/integration/fs-routes-test.ts b/integration/fs-routes-test.ts index 5708622e2a..03d5a723c3 100644 --- a/integration/fs-routes-test.ts +++ b/integration/fs-routes-test.ts @@ -19,14 +19,6 @@ test.describe("fs-routes", () => { test.beforeAll(async () => { fixture = await createFixture({ files: { - "vite.config.js": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - plugins: [reactRouter()], - }); - `, "app/routes.ts": js` import { type RouteConfig } from "@react-router/dev/routes"; import { flatRoutes } from "@react-router/fs-routes"; diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 672faef6dd..bd482ba731 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -18,7 +18,7 @@ import { import { createRequestHandler as createExpressHandler } from "@react-router/express"; import { createReadableStreamFromReadable } from "@react-router/node"; -import { viteConfig, reactRouterConfig } from "./vite.js"; +import { type TemplateName, viteConfig, reactRouterConfig } from "./vite.js"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const root = path.join(__dirname, "../.."); @@ -31,6 +31,7 @@ export interface FixtureInit { spaMode?: boolean; prerender?: boolean; port?: number; + templateName?: TemplateName; } export type Fixture = Awaited>; @@ -362,7 +363,7 @@ export async function createFixtureProject( init: FixtureInit = {}, mode?: ServerMode ): Promise { - let template = "vite-5-template"; + let template = init.templateName ?? "vite-5-template"; let integrationTemplateDir = path.resolve(__dirname, template); let projectName = `rr-${template}-${Math.random().toString(32).slice(2)}`; let projectDir = path.join(TMP_DIR, projectName); @@ -424,6 +425,9 @@ function build(projectDir: string, buildStdio?: Writable, mode?: ServerMode) { env: { ...process.env, NODE_ENV: mode || ServerMode.Production, + // Ensure build can pass in Rolldown. This can be removed once + // "preserveEntrySignatures" is supported in rolldown-vite. + ROLLDOWN_OPTIONS_VALIDATION: "loose", }, }); diff --git a/integration/helpers/vite-rolldown-template/.gitignore b/integration/helpers/vite-rolldown-template/.gitignore new file mode 100644 index 0000000000..c08251ce0e --- /dev/null +++ b/integration/helpers/vite-rolldown-template/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +.env +.react-router diff --git a/integration/helpers/vite-rolldown-template/app/root.tsx b/integration/helpers/vite-rolldown-template/app/root.tsx new file mode 100644 index 0000000000..b36392b4dd --- /dev/null +++ b/integration/helpers/vite-rolldown-template/app/root.tsx @@ -0,0 +1,19 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/vite-rolldown-template/app/routes.ts b/integration/helpers/vite-rolldown-template/app/routes.ts new file mode 100644 index 0000000000..4c05936cb6 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/app/routes.ts @@ -0,0 +1,4 @@ +import { type RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes() satisfies RouteConfig; diff --git a/integration/helpers/vite-rolldown-template/app/routes/_index.tsx b/integration/helpers/vite-rolldown-template/app/routes/_index.tsx new file mode 100644 index 0000000000..ecfc25c614 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/app/routes/_index.tsx @@ -0,0 +1,16 @@ +import type { MetaFunction } from "react-router"; + +export const meta: MetaFunction = () => { + return [ + { title: "New React Router App" }, + { name: "description", content: "Welcome to React Router!" }, + ]; +}; + +export default function Index() { + return ( +
+

Welcome to React Router

+
+ ); +} diff --git a/integration/helpers/vite-rolldown-template/env.d.ts b/integration/helpers/vite-rolldown-template/env.d.ts new file mode 100644 index 0000000000..5e7dfe5dd9 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/integration/helpers/vite-rolldown-template/package.json b/integration/helpers/vite-rolldown-template/package.json new file mode 100644 index 0000000000..01dd8b78a8 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/package.json @@ -0,0 +1,45 @@ +{ + "name": "integration-vite-rolldown-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "react-router dev", + "build": "cross-env ROLLDOWN_OPTIONS_VALIDATION=loose react-router build", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@react-router/express": "workspace:*", + "@react-router/node": "workspace:*", + "@react-router/serve": "workspace:*", + "@vanilla-extract/css": "^1.10.0", + "@vanilla-extract/vite-plugin": "^3.9.2", + "express": "^4.19.2", + "isbot": "^5.1.11", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router": "workspace:*", + "serialize-javascript": "^6.0.1" + }, + "devDependencies": { + "@react-router/dev": "workspace:*", + "@react-router/fs-routes": "workspace:*", + "@react-router/remix-routes-option-adapter": "workspace:*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "cross-env": "^7.0.3", + "eslint": "^8.38.0", + "typescript": "^5.1.6", + "vite": "npm:rolldown-vite@6.3.0-beta.5", + "vite-env-only": "^3.0.1", + "vite-tsconfig-paths": "^4.2.1" + }, + "overrides": { + "vite": "npm:rolldown-vite@6.3.0-beta.5" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/integration/helpers/vite-rolldown-template/public/favicon.ico b/integration/helpers/vite-rolldown-template/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/vite-rolldown-template/public/favicon.ico differ diff --git a/integration/helpers/vite-rolldown-template/tsconfig.json b/integration/helpers/vite-rolldown-template/tsconfig.json new file mode 100644 index 0000000000..62bbb55722 --- /dev/null +++ b/integration/helpers/vite-rolldown-template/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true, + "rootDirs": [".", ".react-router/types/"] + } +} diff --git a/integration/helpers/vite-rolldown-template/vite.config.ts b/integration/helpers/vite-rolldown-template/vite.config.ts new file mode 100644 index 0000000000..fac933f23c --- /dev/null +++ b/integration/helpers/vite-rolldown-template/vite.config.ts @@ -0,0 +1,11 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + // @ts-expect-error `dev` depends on Vite 6, Plugin type is mismatched. + reactRouter(), + tsconfigPaths(), + ], +}); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index be6e7bc0a8..1330c291e1 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -61,14 +61,29 @@ export const reactRouterConfig = ({ `; }; -type ViteConfigArgs = { +type ViteConfigServerArgs = { port: number; fsAllow?: string[]; +}; + +type ViteConfigBuildArgs = { + assetsInlineLimit?: number; + assetsDir?: string; +}; + +type ViteConfigBaseArgs = { envDir?: string; }; +type ViteConfigArgs = ( + | ViteConfigServerArgs + | { [K in keyof ViteConfigServerArgs]?: never } +) & + ViteConfigBuildArgs & + ViteConfigBaseArgs; + export const viteConfig = { - server: async (args: ViteConfigArgs) => { + server: async (args: ViteConfigServerArgs) => { let { port, fsAllow } = args; let hmrPort = await getPort(); let text = dedent` @@ -81,21 +96,42 @@ export const viteConfig = { `; return text; }, + build: ({ assetsInlineLimit, assetsDir }: ViteConfigBuildArgs = {}) => { + return dedent` + build: { + // Detect rolldown-vite. This should ideally use "rolldownVersion" + // but that's not exported. Once that's available, this + // check should be updated to use it. + rollupOptions: "transformWithOxc" in (await import("vite")) + ? { + onwarn(warning, warn) { + // Ignore "The built-in minifier is still under development." warning + if (warning.code === "MINIFY_WARNING") return; + warn(warning); + }, + } + : undefined, + assetsInlineLimit: ${assetsInlineLimit ?? "undefined"}, + assetsDir: ${assetsDir ? `"${assetsDir}"` : "undefined"}, + }, + `; + }, basic: async (args: ViteConfigArgs) => { return dedent` import { reactRouter } from "@react-router/dev/vite"; import { envOnlyMacros } from "vite-env-only"; import tsconfigPaths from "vite-tsconfig-paths"; - export default { - ${await viteConfig.server(args)} + export default async () => ({ + ${args.port ? await viteConfig.server(args) : ""} + ${viteConfig.build(args)} envDir: ${args.envDir ? `"${args.envDir}"` : "undefined"}, plugins: [ reactRouter(), envOnlyMacros(), tsconfigPaths() ], - }; + }); `; }, }; @@ -145,14 +181,19 @@ export const EXPRESS_SERVER = (args: { `; export type TemplateName = + | "cloudflare-dev-proxy-template" | "vite-5-template" | "vite-6-template" - | "cloudflare-dev-proxy-template" - | "vite-plugin-cloudflare-template"; + | "vite-plugin-cloudflare-template" + | "vite-rolldown-template"; export const viteMajorTemplates = [ { templateName: "vite-5-template", templateDisplayName: "Vite 5" }, { templateName: "vite-6-template", templateDisplayName: "Vite 6" }, + { + templateName: "vite-rolldown-template", + templateDisplayName: "Vite Rolldown", + }, ] as const satisfies Array<{ templateName: TemplateName; templateDisplayName: string; @@ -205,6 +246,9 @@ export const build = ({ ...process.env, ...colorEnv, ...env, + // Ensure build can pass in Rolldown. This can be removed once + // "preserveEntrySignatures" is supported in rolldown-vite. + ROLLDOWN_OPTIONS_VALIDATION: "loose", }, }); }; diff --git a/integration/link-test.ts b/integration/link-test.ts index 1c7ffb3c23..4d2d331f43 100644 --- a/integration/link-test.ts +++ b/integration/link-test.ts @@ -357,8 +357,7 @@ test.describe("route module link export", () => { `, "app/routes/gists.$username.tsx": js` - import { data, redirect } from "react-router"; - import { Link, useLoaderData, useParams } from "react-router"; + import { data, redirect, Link, useLoaderData, useParams } from "react-router"; export async function loader({ params }) { let { username } = params; if (username === "mjijackson") { diff --git a/integration/multiple-cookies-test.ts b/integration/multiple-cookies-test.ts index 96eabed064..e83b5640fb 100644 --- a/integration/multiple-cookies-test.ts +++ b/integration/multiple-cookies-test.ts @@ -16,8 +16,7 @@ test.describe("pathless layout routes", () => { await createFixture({ files: { "app/routes/_index.tsx": js` - import { data, redirect } from "react-router"; - import { Form, useActionData } from "react-router"; + import { data, redirect, Form, useActionData } from "react-router"; export let loader = async () => { let headers = new Headers(); diff --git a/integration/prefetch-test.ts b/integration/prefetch-test.ts index 211030f1d7..bee1b75991 100644 --- a/integration/prefetch-test.ts +++ b/integration/prefetch-test.ts @@ -12,12 +12,17 @@ import type { AppFixture, } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { type TemplateName, viteMajorTemplates } from "./helpers/vite.js"; type PrefetchType = "intent" | "render" | "none" | "viewport"; // Generate the test app using the given prefetch mode -function fixtureFactory(mode: PrefetchType): FixtureInit { +function fixtureFactory( + mode: PrefetchType, + templateName: TemplateName +): FixtureInit { return { + templateName, files: { "app/root.tsx": js` import { @@ -84,530 +89,561 @@ function fixtureFactory(mode: PrefetchType): FixtureInit { }; } -test.describe("prefetch=none", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("none")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); -}); - -test.describe("prefetch=render", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("render")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("adds prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // Both data and asset fetch for /with-loader - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", - { state: "attached" } - ); - - // Only asset fetch for /without-loader - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", - { state: "attached" } - ); - - // These 2 are common and duped for both - but they've already loaded on - // page load so they don't trigger network requests - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - - // Ensure no other links in the #nav element - expect(await page.locator("#nav link").count()).toBe(7); - }); -}); - -test.describe("prefetch=intent (hover)", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("intent")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); - - test("adds prefetch tags on hover", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.hover("a[href='http://github.com/with-loader']"); - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", - { state: "attached" } - ); - // Check href prefix due to hashed filenames - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(4); - - await page.hover("a[href='http://github.com/without-loader']"); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(3); - }); - - test("removes prefetch tags after navigating to/from the page", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // Links added on hover - await page.hover("a[href='http://github.com/with-loader']"); - await page.waitForSelector("#nav link", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(4); - - // Links removed upon navigating to the page - await page.click("a[href='http://github.com/with-loader']"); - await page.waitForSelector("h2.with-loader", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(0); - - // Links stay removed upon navigating away from the page - await page.click("a[href='http://github.com/without-loader']"); - await page.waitForSelector("h2.without-loader", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(0); - }); -}); - -test.describe("prefetch=intent (focus)", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("intent")); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); +test.describe("prefetch", () => { + viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + test.describe(templateDisplayName, () => { + test.describe("prefetch=none", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("none", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + }); - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); + test.describe("prefetch=render", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("render", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Both data and asset fetch for /with-loader + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", + { state: "attached" } + ); - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); + // Only asset fetch for /without-loader + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", + { state: "attached" } + ); - test("adds prefetch tags on focus", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - // This click is needed to transfer focus to the main window, allowing - // subsequent focus events to fire - await page.click("body"); - await page.focus("a[href='http://github.com/with-loader']"); - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(4); - - await page.focus("a[href='http://github.com/without-loader']"); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/with-props-']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/assets/chunk-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(3); - }); -}); + // These 2 are common and duped for both - but they've already loaded on + // page load so they don't trigger network requests + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); -test.describe("prefetch=viewport", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` - import { Link } from "react-router"; - - export default function Component() { - return ( - <> -

Index Page - Scroll Down

-
- Click me! -
- - ); - } - `, - - "app/routes/test.tsx": js` - export function loader() { - return null; - } - export default function Component() { - return

Test Page

; - } - `, - }, - }); + // Ensure no other links in the #nav element + expect(await page.locator("#nav link").count()).toBe(7); + }); + }); - // This creates an interactive app using puppeteer. - appFixture = await createAppFixture(fixture); - }); + test.describe("prefetch=intent (hover)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on hover", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='http://github.com/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", + { state: "attached" } + ); + // Check href prefix due to hashed filenames + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(4); - test.afterAll(() => { - appFixture.close(); - }); + await page.hover("a[href='http://github.com/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(3); + }); + + test("removes prefetch tags after navigating to/from the page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Links added on hover + await page.hover("a[href='http://github.com/with-loader']"); + await page.waitForSelector("#nav link", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(4); + + // Links removed upon navigating to the page + await page.click("a[href='http://github.com/with-loader']"); + await page.waitForSelector("h2.with-loader", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(0); + + // Links stay removed upon navigating away from the page + await page.click("a[href='http://github.com/without-loader']"); + await page.waitForSelector("h2.without-loader", { + state: "attached", + }); + expect(await page.locator("#nav link").count()).toBe(0); + }); + }); - test("should prefetch when the link enters the viewport", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // No preloads to start - await expect(page.locator("div link")).toHaveCount(0); - - // Preloads render on scroll down - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - - await page.waitForSelector( - "div link[rel='prefetch'][as='fetch'][href='http://github.com/test.data']", - { state: "attached" } - ); - await page.waitForSelector( - "div link[rel='modulepreload'][href^='/assets/test-']", - { state: "attached" } - ); - - // Preloads removed on scroll up - await page.evaluate(() => window.scrollTo(0, 0)); - await expect(page.locator("div link")).toHaveCount(0); - }); -}); + test.describe("prefetch=intent (focus)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent", templateName)); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on focus", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // This click is needed to transfer focus to the main window, allowing + // subsequent focus events to fire + await page.click("body"); + await page.focus("a[href='http://github.com/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='http://github.com/with-loader.data']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(4); -test.describe("other scenarios", () => { - let fixture: Fixture; - let appFixture: AppFixture; + await page.focus("a[href='http://github.com/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/without-loader-']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/assets/with-props-']", + { state: "attached" } + ); + await page.waitForSelector( + // Look for either Rollup or Rolldown chunks + [ + "#nav link[rel='modulepreload'][href^='/assets/chunk-']", + "#nav link[rel='modulepreload'][href^='/assets/jsx-runtime-']", + ].join(","), + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(3); + }); + }); - test.afterAll(() => { - appFixture?.close(); - }); + test.describe("prefetch=viewport", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Component() { + return ( + <> +

Index Page - Scroll Down

+
+ Click me! +
+ + ); + } + `, + + "app/routes/test.tsx": js` + export function loader() { + return null; + } + export default function Component() { + return

Test Page

; + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should prefetch when the link enters the viewport", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // No preloads to start + await expect(page.locator("div link")).toHaveCount(0); + + // Preloads render on scroll down + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight) + ); - test("does not add prefetch links for stylesheets already in the DOM (active routes)", async ({ - page, - }) => { - fixture = await createFixture({ - files: { - "app/root.tsx": js` - import { Links, Meta, Scripts, useFetcher } from "react-router"; - import globalCss from "./global.css?url"; - - export function links() { - return [{ rel: "stylesheet", href: globalCss }]; - } - - export async function action() { - return null; - } - - export async function loader() { - return null; - } - - export default function Root() { - let fetcher = useFetcher(); - - return ( - - - - - - - -

{fetcher.state}

- - - - ); - } - `, - - "app/global.css": ` - body { - background-color: black; - color: white; - } - `, - - "app/routes/_index.tsx": js` - export default function() { - return

Index

; - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - let requests: { type: string; url: string }[] = []; + await page.waitForSelector( + "div link[rel='prefetch'][as='fetch'][href='http://github.com/test.data']", + { state: "attached" } + ); + await page.waitForSelector( + "div link[rel='modulepreload'][href^='/assets/test-']", + { state: "attached" } + ); - page.on("request", (req) => { - requests.push({ - type: req.resourceType(), - url: req.url(), + // Preloads removed on scroll up + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator("div link")).toHaveCount(0); + }); }); - }); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.click("#submit-fetcher"); - await page.waitForSelector("#fetcher-state--idle"); - // We should not send a second request for this root stylesheet that's - // already been rendered in the DOM - let stylesheets = requests.filter( - (r) => r.type === "stylesheet" && /\/global-[a-z0-9]+\.css/i.test(r.url) - ); - expect(stylesheets.length).toBe(1); - }); - - test("dedupes prefetch tags", async ({ page }) => { - fixture = await createFixture({ - files: { - "app/root.tsx": js` - import { - Link, - Links, - Meta, - Outlet, - Scripts, - useLoaderData, - } from "react-router"; - - export default function Root() { - const styles = - 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + - 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; - - return ( - - - - - - - -

Root

- - - - - - ); - } - `, - - "app/global.css": css` - .global-class { - background-color: gray; - color: black; - } - `, - - "app/local.css": css` - .local-class { - background-color: black; - color: white; - } - `, - - "app/routes/_index.tsx": js` - export default function() { - return

Index

; - } - `, - - "app/routes/with-nested-links.tsx": js` - import { Outlet } from "react-router"; - import globalCss from "../global.css?url"; - - export function links() { - return [ - // Same links as child route but with different key order - { - rel: "stylesheet", - href: globalCss, - }, - { - rel: "preload", - as: "image", - imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", - imageSizes: "9999px", - }, - ]; - } - export default function() { - return ; - } - `, - - "app/routes/with-nested-links.nested.tsx": js` - import globalCss from '../global.css?url'; - import localCss from '../local.css?url'; - - export function links() { - return [ - // Same links as parent route but with different key order - { - href: globalCss, - rel: "stylesheet", - }, - { - imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", - imageSizes: "9999px", - rel: "preload", - as: "image", - }, - // Unique links for child route - { - rel: "stylesheet", - href: localCss, - }, - { - rel: "preload", - as: "image", - imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w", - imageSizes: "9999px", - }, - ]; - } - export default function() { - return

With Nested Links

; - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.hover("a[href='http://github.com/with-nested-links/nested']"); - await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { - state: "attached", + test.describe("other scenarios", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture?.close(); + }); + + test("does not add prefetch links for stylesheets already in the DOM (active routes)", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Scripts, useFetcher } from "react-router"; + import globalCss from "./global.css?url"; + + export function links() { + return [{ rel: "stylesheet", href: globalCss }]; + } + + export async function action() { + return null; + } + + export async function loader() { + return null; + } + + export default function Root() { + let fetcher = useFetcher(); + + return ( + + + + + + + +

{fetcher.state}

+ + + + ); + } + `, + + "app/global.css": ` + body { + background-color: black; + color: white; + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + let requests: { type: string; url: string }[] = []; + + page.on("request", (req) => { + requests.push({ + type: req.resourceType(), + url: req.url(), + }); + }); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.click("#submit-fetcher"); + await page.waitForSelector("#fetcher-state--idle"); + // We should not send a second request for this root stylesheet that's + // already been rendered in the DOM + let stylesheets = requests.filter( + (r) => + r.type === "stylesheet" && /\/global-[a-z0-9-]+\.css/i.test(r.url) + ); + expect(stylesheets.length).toBe(1); + }); + + test("dedupes prefetch tags", async ({ page }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "react-router"; + + export default function Root() { + const styles = + 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + + 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; + + return ( + + + + + + + +

Root

+ + + + + + ); + } + `, + + "app/global.css": css` + .global-class { + background-color: gray; + color: black; + } + `, + + "app/local.css": css` + .local-class { + background-color: black; + color: white; + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, + + "app/routes/with-nested-links.tsx": js` + import { Outlet } from "react-router"; + import globalCss from "../global.css?url"; + + export function links() { + return [ + // Same links as child route but with different key order + { + rel: "stylesheet", + href: globalCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return ; + } + `, + + "app/routes/with-nested-links.nested.tsx": js` + import globalCss from '../global.css?url'; + import localCss from '../local.css?url'; + + export function links() { + return [ + // Same links as parent route but with different key order + { + href: globalCss, + rel: "stylesheet", + }, + { + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + rel: "preload", + as: "image", + }, + // Unique links for child route + { + rel: "stylesheet", + href: localCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return

With Nested Links

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='http://github.com/with-nested-links/nested']"); + await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { + state: "attached", + }); + expect( + await page.locator("#nav link[rel='prefetch'][as='style']").count() + ).toBe(2); + expect( + await page.locator("#nav link[rel='prefetch'][as='image']").count() + ).toBe(2); + }); + }); }); - expect( - await page.locator("#nav link[rel='prefetch'][as='style']").count() - ).toBe(2); - expect( - await page.locator("#nav link[rel='prefetch'][as='image']").count() - ).toBe(2); }); }); diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts index b498f0a0d8..f3eefd7963 100644 --- a/integration/redirects-test.ts +++ b/integration/redirects-test.ts @@ -35,8 +35,7 @@ test.describe("redirects", () => { `, "app/routes/absolute._index.tsx": js` - import { redirect } from "react-router"; - import { Form } from "react-router"; + import { redirect, Form } from "react-router"; export async function action({ request }) { return redirect(new URL(request.url).origin + "/absolute/landing"); diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts index 1e607c754f..0d02cef85f 100644 --- a/integration/resource-routes-test.ts +++ b/integration/resource-routes-test.ts @@ -114,7 +114,6 @@ test.describe("loader in an app", async () => { } `, "app/routes/$.tsx": js` - import { json } from "react-router"; import { useRouteError } from "react-router"; export function loader({ request }) { throw Response.json({ diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index 96f7ef6228..b14c73ff5d 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -46,8 +46,7 @@ test.describe("Revalidation", () => { `, "app/routes/parent.tsx": js` - import { data } from "react-router"; - import { Outlet, useLoaderData } from "react-router"; + import { data, Outlet, useLoaderData } from "react-router"; export async function loader({ request }) { let header = request.headers.get('Cookie') || ''; @@ -86,8 +85,7 @@ test.describe("Revalidation", () => { `, "app/routes/parent.child.tsx": js` - import { data } from "react-router"; - import { Form, useLoaderData, useRevalidator } from "react-router"; + import { data, Form, useLoaderData, useRevalidator } from "react-router"; export async function action() { return { action: 'data' } diff --git a/integration/scroll-test.ts b/integration/scroll-test.ts index 93f3a1978c..51056397d1 100644 --- a/integration/scroll-test.ts +++ b/integration/scroll-test.ts @@ -15,8 +15,7 @@ test.beforeAll(async () => { fixture = await createFixture({ files: { "app/routes/_index.tsx": js` - import { redirect } from "react-router"; - import { Form } from "react-router"; + import { redirect, Form } from "react-router"; export function action() { return redirect("/test"); diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 51a670b208..358922905d 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -408,6 +408,80 @@ test.describe("single-fetch", () => { ]); }); + test("revalidates on reused routes by default", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return Go to Parent + } + `, + "app/routes/parent.tsx": js` + import { Link, Outlet } from "react-router"; + import type { Route } from "./+types/parent"; + + let count = 0; + export function loader() { + return ++count; + } + + export default function Parent({ loaderData }: Route.ComponentProps) { + return ( + <> +

PARENT:{loaderData}

+ Go to Parent
+ Go to Child + + + ); + } + `, + "app/routes/parent.child.tsx": js` + import { Outlet } from "react-router"; + import type { Route } from "./+types/parent"; + + export function loader() { + return "CHILD" + } + + export default function Parent({ loaderData }: Route.ComponentProps) { + return

{loaderData}

+ } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (req.method() === "GET" && url.pathname.endsWith(".data")) { + urls.push(url.pathname + url.search); + } + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + await app.clickLink("/parent"); + await page.waitForSelector('[data-parent="1"]'); + expect(urls).toEqual(["/parent.data"]); + urls.length = 0; + + await app.clickLink("/parent/child"); + await page.waitForSelector("[data-child]"); + await expect(page.locator('[data-parent="2"]')).toBeDefined(); + expect(urls).toEqual(["/parent/child.data"]); + urls.length = 0; + + await app.clickLink("/parent"); + await page.waitForSelector('[data-parent="3"]'); + expect(urls).toEqual(["/parent.data"]); + urls.length = 0; + }); + test("does not revalidate on 4xx/5xx action responses", async ({ page }) => { let fixture = await createFixture({ files: { diff --git a/integration/split-route-modules-test.ts b/integration/split-route-modules-test.ts index 404673504c..cc35670c4a 100644 --- a/integration/split-route-modules-test.ts +++ b/integration/split-route-modules-test.ts @@ -56,7 +56,7 @@ const files = { } })(); const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Client loader wasn't unblocked after 2s")), 2000); + setTimeout(() => reject(new Error("Client loader wasn't unblocked after 5s")), 5000); }); await Promise.race([pollingPromise, timeoutPromise]); return { @@ -131,7 +131,7 @@ const files = { } })(); const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Client loader wasn't unblocked after 2s")), 2000); + setTimeout(() => reject(new Error("Client loader wasn't unblocked after 5s")), 5000); }); await Promise.race([pollingPromise, timeoutPromise]); return "clientLoader in main chunk: " + eval("typeof inUnsplittableMainChunk === 'function'"); @@ -419,8 +419,10 @@ test.describe("Split route modules", async () => { // Ensure splittable client loader works during SSR await page.goto(`http://localhost:${port}/splittable`); - expect(page.locator("[data-hydrate-fallback]")).toHaveText("Loading..."); - expect(page.locator("[data-hydrate-fallback]")).toHaveCSS( + await expect(page.locator("[data-hydrate-fallback]")).toHaveText( + "Loading..." + ); + await expect(page.locator("[data-hydrate-fallback]")).toHaveCSS( "padding", "20px" ); @@ -431,7 +433,9 @@ test.describe("Split route modules", async () => { // Ensure unsplittable client loader works during SSR await page.goto(`http://localhost:${port}/unsplittable`); - expect(page.locator("[data-hydrate-fallback]")).toHaveText("Loading..."); + await expect(page.locator("[data-hydrate-fallback]")).toHaveText( + "Loading..." + ); await unblockClientLoader(page); await expect(page.locator("[data-loader-data]")).toHaveText( `loaderData = "clientLoader in main chunk: true"` diff --git a/integration/transition-test.ts b/integration/transition-test.ts index 1521257e6b..78e3cc16ce 100644 --- a/integration/transition-test.ts +++ b/integration/transition-test.ts @@ -139,8 +139,7 @@ test.describe("rendering", () => { `, "app/routes/gh-1691.tsx": js` - import { json, redirect } from "react-router"; - import { useFetcher} from "react-router"; + import { redirect, useFetcher } from "react-router"; export const action = async ( ) => { return redirect("/gh-1691"); @@ -195,8 +194,7 @@ test.describe("rendering", () => { `, "app/routes/parent.child.tsx": js` - import { redirect } from "react-router"; - import { useFetcher} from "react-router"; + import { redirect, useFetcher } from "react-router"; export const action = async ({ request }) => { return redirect("/parent"); diff --git a/integration/vite-basename-test.ts b/integration/vite-basename-test.ts index 111a9201e8..af4c3bf53b 100644 --- a/integration/vite-basename-test.ts +++ b/integration/vite-basename-test.ts @@ -70,14 +70,15 @@ async function configFiles({ basename: basename !== "/" ? basename : undefined, }), "vite.config.js": js` - import { reactRouter } from "@react-router/dev/vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default { - ${base !== "/" ? 'base: "' + base + '",' : ""} - ${await viteConfig.server({ port })} - plugins: [reactRouter()] - } - `, + export default async () => ({ + ${base !== "/" ? 'base: "' + base + '",' : ""} + ${await viteConfig.server({ port })} + ${viteConfig.build()} + plugins: [reactRouter()] + }) + `, }; } diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts index 59155b5502..40f81ef097 100644 --- a/integration/vite-build-test.ts +++ b/integration/vite-build-test.ts @@ -10,6 +10,7 @@ import { reactRouterConfig, viteConfig, grep, + viteMajorTemplates, } from "./helpers/vite.js"; let port: number; @@ -20,364 +21,376 @@ const js = String.raw; test.describe("Build", () => { [false, true].forEach((viteEnvironmentApi) => { - test.describe(`viteEnvironmentApi enabled: ${viteEnvironmentApi}`, () => { - test.beforeAll(async () => { - port = await getPort(); - cwd = await createProject( - { - ".env": ` - ENV_VAR_FROM_DOTENV_FILE=true - `, - "react-router.config.ts": reactRouterConfig({ viteEnvironmentApi }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - import mdx from "@mdx-js/rollup"; - - export default defineConfig({ - ${await viteConfig.server({ port })} - build: { - // force emitting asset files instead of inlined as data-url - assetsInlineLimit: 0, - }, - plugins: [ - mdx(), - reactRouter(), - ], - }); - `, - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router"; - - export default function Root() { - return ( - - - - - - -
-

Root

- -
- - - - ); - } - `, - "app/routes/_index.tsx": js` - import { useState, useEffect } from "react"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return { serverOnly1 } - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export default function() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - return ( - <> -

Index

- {!mounted ?

Loading...

:

Mounted

} - - ); - } - `, - "app/utils.server.ts": js` - export const serverOnly1 = "SERVER_ONLY_1" - export const serverOnly2 = "SERVER_ONLY_2" - `, - "app/routes/resource.ts": js` - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return { serverOnly1 } - } - - export const action = () => { - console.log(serverOnly2) - return null - } - `, - "app/routes/mdx.mdx": js` - import { useEffect, useState } from "react"; - import { useLoaderData } from "react-router"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return { - serverOnly1, - content: "MDX route content from loader", + viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + // Vite 5 doesn't support the Environment API + if (templateName === "vite-5-template" && viteEnvironmentApi) { + return; + } + + test.describe(`${templateDisplayName}${ + viteEnvironmentApi ? " with Vite Environment API" : "" + }`, () => { + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject( + { + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=true + `, + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi, + }), + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + ${await viteConfig.server({ port })} + ${viteConfig.build({ + assetsInlineLimit: 0, + })} + plugins: [ + mdx(), + reactRouter(), + ], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + ); } - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export function MdxComponent() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - const { content } = useLoaderData(); - const text = content + (mounted - ? ": mounted" - : ": not mounted"); - return
{text}
- } - - ## MDX Route - - - `, - "app/routes/code-split1.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit1Route() { - return
; - } - `, - "app/routes/code-split2.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit2Route() { - return
; - } - `, - "app/code-split-component.tsx": js` - import classes from "./code-split.module.css"; - - export function CodeSplitComponent() { - return ok - } - `, - "app/code-split.module.css": js` - .test { - background-color: rgb(255, 170, 0); - } - `, - "app/routes/dotenv.tsx": js` - import { useLoaderData } from "react-router"; - - export const loader = () => { - return { - loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', + `, + "app/routes/_index.tsx": js` + import { useState, useEffect } from "react"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return { serverOnly1 } } - } - - export default function DotenvRoute() { - const { loaderContent } = useLoaderData(); - - return
{loaderContent}
; - } - `, - - "app/assets/test.txt": "test", - "app/routes/ssr-only-assets.tsx": js` - import txtUrl from "../assets/test.txt?url"; - import { useLoaderData } from "react-router" - - export const loader: LoaderFunction = () => { - return { txtUrl }; - }; - - export default function SsrOnlyAssetsRoute() { - const loaderData = useLoaderData(); - return ( -
- txtUrl -
- ); - } - `, - - "app/assets/test.css": ".test{color:red}", - "app/routes/ssr-only-css-url-files.tsx": js` - import cssUrl from "../assets/test.css?url"; - import { useLoaderData } from "react-router" - - export const loader: LoaderFunction = () => { - return { cssUrl }; - }; - - export default function SsrOnlyCssUrlFilesRoute() { - const loaderData = useLoaderData(); - return ( -
- cssUrl -
- ); - } - `, - - "app/routes/ssr-code-split.tsx": js` - import { useLoaderData } from "react-router" - - export const loader: LoaderFunction = async () => { - const lib = await import("../ssr-code-split-lib"); - return lib.ssrCodeSplitTest(); - }; - - export default function SsrCodeSplitRoute() { - const loaderData = useLoaderData(); - return ( -
{loaderData}
- ); - } - `, - - "app/ssr-code-split-lib.ts": js` - export function ssrCodeSplitTest() { - return "ssrCodeSplitTest"; - } - `, - }, - viteEnvironmentApi ? "vite-6-template" : "vite-5-template" - ); - - let { stderr, status } = build({ cwd }); - expect( - stderr - .toString() - // This can be removed when this issue is fixed: https://github.com/remix-run/remix/issues/9055 - .replace('Generated an empty chunk: "resource".', "") - .trim() - ).toBeFalsy(); - expect(status).toBe(0); - stop = await reactRouterServe({ cwd, port }); - }); - test.afterAll(() => stop()); - - test("server code is removed from client build", async () => { - expect( - grep(path.join(cwd, "build/client"), /SERVER_ONLY_1/).length - ).toBe(0); - expect( - grep(path.join(cwd, "build/client"), /SERVER_ONLY_2/).length - ).toBe(0); - }); - test("renders matching MDX routes", async ({ page }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + export const action = () => { + console.log(serverOnly2) + return null + } - await page.goto(`http://localhost:${port}/mdx`, { - waitUntil: "networkidle", - }); - await expect(page.locator("[data-mdx-route]")).toHaveText( - "MDX route content from loader: mounted" - ); - expect(pageErrors).toEqual([]); - }); + export default function() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( + <> +

Index

+ {!mounted ?

Loading...

:

Mounted

} + + ); + } + `, + "app/utils.server.ts": js` + export const serverOnly1 = "SERVER_ONLY_1" + export const serverOnly2 = "SERVER_ONLY_2" + `, + "app/routes/resource.ts": js` + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return { serverOnly1 } + } - test("emits SSR-only assets to the client assets directory", async ({ - page, - }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + export const action = () => { + console.log(serverOnly2) + return null + } + `, + "app/routes/mdx.mdx": js` + import { useEffect, useState } from "react"; + import { useLoaderData } from "react-router"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return { + serverOnly1, + content: "MDX route content from loader", + } + } - await page.goto(`http://localhost:${port}/ssr-only-assets`, { - waitUntil: "networkidle", - }); + export const action = () => { + console.log(serverOnly2) + return null + } - await page.getByRole("link", { name: "txtUrl" }).click(); - await page.waitForURL("**/assets/test-*.txt"); - await expect(page.getByText("test")).toBeVisible(); - expect(pageErrors).toEqual([]); - }); + export function MdxComponent() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + const { content } = useLoaderData(); + const text = content + (mounted + ? ": mounted" + : ": not mounted"); + return
{text}
+ } - test("emits SSR-only .css?url files to the client assets directory", async ({ - page, - }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + ## MDX Route - await page.goto(`http://localhost:${port}/ssr-only-css-url-files`, { - waitUntil: "networkidle", - }); + + `, + "app/routes/code-split1.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; - await page.getByRole("link", { name: "cssUrl" }).click(); - await page.waitForURL("**/assets/test-*.css"); - await expect(page.getByText(".test{")).toBeVisible(); - expect(pageErrors).toEqual([]); - }); + export default function CodeSplit1Route() { + return
; + } + `, + "app/routes/code-split2.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit2Route() { + return
; + } + `, + "app/code-split-component.tsx": js` + import classes from "./code-split.module.css"; + + export function CodeSplitComponent() { + return ok + } + `, + "app/code-split.module.css": js` + .test { + background-color: rgb(255, 170, 0); + } + `, + "app/routes/dotenv.tsx": js` + import { useLoaderData } from "react-router"; + + export const loader = () => { + return { + loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', + } + } - test("supports code-split JS from SSR build", async ({ page }) => { - let pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); - await page.goto(`http://localhost:${port}/ssr-code-split`, { - waitUntil: "networkidle", + return
{loaderContent}
; + } + `, + + "app/assets/test.txt": "test", + "app/routes/ssr-only-assets.tsx": js` + import txtUrl from "../assets/test.txt?url"; + import { useLoaderData } from "react-router" + + export const loader: LoaderFunction = () => { + return { txtUrl }; + }; + + export default function SsrOnlyAssetsRoute() { + const loaderData = useLoaderData(); + return ( +
+ txtUrl +
+ ); + } + `, + + "app/assets/test.css": ".test{color:red}", + "app/routes/ssr-only-css-url-files.tsx": js` + import cssUrl from "../assets/test.css?url"; + import { useLoaderData } from "react-router" + + export const loader: LoaderFunction = () => { + return { cssUrl }; + }; + + export default function SsrOnlyCssUrlFilesRoute() { + const loaderData = useLoaderData(); + return ( +
+ cssUrl +
+ ); + } + `, + + "app/routes/ssr-code-split.tsx": js` + import { useLoaderData } from "react-router" + + export const loader: LoaderFunction = async () => { + const lib = await import("../ssr-code-split-lib"); + return lib.ssrCodeSplitTest(); + }; + + export default function SsrCodeSplitRoute() { + const loaderData = useLoaderData(); + return ( +
{loaderData}
+ ); + } + `, + + "app/ssr-code-split-lib.ts": js` + export function ssrCodeSplitTest() { + return "ssrCodeSplitTest"; + } + `, + }, + templateName + ); + + let { stderr, status } = build({ cwd }); + expect( + stderr + .toString() + // This can be removed when this issue is fixed: https://github.com/remix-run/remix/issues/9055 + .replace('Generated an empty chunk: "resource".', "") + .trim() + ).toBeFalsy(); + expect(status).toBe(0); + stop = await reactRouterServe({ cwd, port }); + }); + test.afterAll(() => stop()); + + test("server code is removed from client build", async () => { + expect( + grep(path.join(cwd, "build/client"), /SERVER_ONLY_1/).length + ).toBe(0); + expect( + grep(path.join(cwd, "build/client"), /SERVER_ONLY_2/).length + ).toBe(0); }); - await expect(page.locator("[data-ssr-code-split]")).toHaveText( - "ssrCodeSplitTest" - ); - expect(pageErrors).toEqual([]); - }); + test("renders matching MDX routes", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/mdx`, { + waitUntil: "networkidle", + }); + await expect(page.locator("[data-mdx-route]")).toHaveText( + "MDX route content from loader: mounted" + ); + expect(pageErrors).toEqual([]); + }); - test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { - let assetFiles = glob.sync("build/server/assets/**/*", { cwd }); - let [asset, ...rest] = assetFiles; - expect(rest).toEqual([]); // Provide more useful test output if this fails - expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); - }); + test("emits SSR-only assets to the client assets directory", async ({ + page, + }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - test("supports code-split CSS", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + await page.goto(`http://localhost:${port}/ssr-only-assets`, { + waitUntil: "networkidle", + }); - await page.goto(`http://localhost:${port}/code-split1`, { - waitUntil: "networkidle", + await page.getByRole("link", { name: "txtUrl" }).click(); + await page.waitForURL("**/assets/test-*.txt"); + await expect(page.getByText("test")).toBeVisible(); + expect(pageErrors).toEqual([]); }); - expect( - await page - .locator("#code-split1 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - - await page.goto(`http://localhost:${port}/code-split2`, { - waitUntil: "networkidle", + + test("emits SSR-only .css?url files to the client assets directory", async ({ + page, + }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/ssr-only-css-url-files`, { + waitUntil: "networkidle", + }); + + await page.getByRole("link", { name: "cssUrl" }).click(); + await page.waitForURL("**/assets/test-*.css"); + await expect(page.getByText(".test{")).toBeVisible(); + expect(pageErrors).toEqual([]); }); - expect( - await page - .locator("#code-split2 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - expect(pageErrors).toEqual([]); - }); + test("supports code-split JS from SSR build", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/ssr-code-split`, { + waitUntil: "networkidle", + }); - test("doesn't load .env file", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + await expect(page.locator("[data-ssr-code-split]")).toHaveText( + "ssrCodeSplitTest" + ); + expect(pageErrors).toEqual([]); + }); + + test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { + let assetFiles = glob.sync("build/server/assets/**/*", { cwd }); + let [asset, ...rest] = assetFiles; + expect(rest).toEqual([]); // Provide more useful test output if this fails + expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); + }); - await page.goto(`http://localhost:${port}/dotenv`, { - waitUntil: "networkidle", + test("supports code-split CSS", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/code-split1`, { + waitUntil: "networkidle", + }); + expect( + await page + .locator("#code-split1 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + await page.goto(`http://localhost:${port}/code-split2`, { + waitUntil: "networkidle", + }); + expect( + await page + .locator("#code-split2 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + expect(pageErrors).toEqual([]); }); - expect(pageErrors).toEqual([]); - let loaderContent = page.locator("[data-dotenv-route-loader-content]"); - await expect(loaderContent).toHaveText( - ".env file was NOT loaded, which is a good thing" - ); + test("doesn't load .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - expect(pageErrors).toEqual([]); + await page.goto(`http://localhost:${port}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let loaderContent = page.locator( + "[data-dotenv-route-loader-content]" + ); + await expect(loaderContent).toHaveText( + ".env file was NOT loaded, which is a good thing" + ); + + expect(pageErrors).toEqual([]); + }); }); }); }); diff --git a/integration/vite-css-test.ts b/integration/vite-css-test.ts index 257f04286c..866ec4bdfd 100644 --- a/integration/vite-css-test.ts +++ b/integration/vite-css-test.ts @@ -162,8 +162,9 @@ const VITE_CONFIG = async ({ import { reactRouter } from "@react-router/dev/vite"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; - export default { + export default async () => ({ ${await viteConfig.server({ port })} + ${viteConfig.build()} ${base ? `base: "${base}",` : ""} plugins: [ reactRouter(), @@ -171,7 +172,7 @@ const VITE_CONFIG = async ({ emitCssInSsr: true, }), ], - } + }); `; test.describe("Vite CSS", () => { @@ -260,11 +261,14 @@ test.describe("Vite CSS", () => { test.beforeAll(async () => { port = await getPort(); - cwd = await createProject({ - "vite.config.ts": await VITE_CONFIG({ port }), - "server.mjs": EXPRESS_SERVER({ port }), - ...files, - }); + cwd = await createProject( + { + "vite.config.ts": await VITE_CONFIG({ port }), + "server.mjs": EXPRESS_SERVER({ port }), + ...files, + }, + templateName + ); stop = await customDev({ cwd, port }); }); test.afterAll(() => stop()); @@ -292,10 +296,13 @@ test.describe("Vite CSS", () => { test.beforeAll(async () => { port = await getPort(); - cwd = await createProject({ - "vite.config.ts": await VITE_CONFIG({ port }), - ...files, - }); + cwd = await createProject( + { + "vite.config.ts": await VITE_CONFIG({ port }), + ...files, + }, + templateName + ); let edit = createEditor(cwd); await edit("package.json", (contents) => diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index c6b0c6d18c..182271a085 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -54,6 +54,7 @@ let files = { Home
About
Not Found
+ Redirect
{children} @@ -169,38 +170,38 @@ test.describe("Prerendering", () => { files: { ...files, "app/routes/parent.tsx": js` - import { Outlet } from 'react-router' - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export default function Component() { + return + } + `, "app/routes/parent.child.tsx": js` - import { Outlet } from 'react-router' - export function loader() { - return null; - } - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export function loader() { + return null; + } + export default function Component() { + return + } + `, "app/routes/$slug.tsx": js` - import { Outlet } from 'react-router' - export function loader() { - return null; - } - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export function loader() { + return null; + } + export default function Component() { + return + } + `, "app/routes/$.tsx": js` - import { Outlet } from 'react-router' - export function loader() { - return null; - } - export default function Component() { - return - } - `, + import { Outlet } from 'react-router' + export function loader() { + return null; + } + export default function Component() { + return + } + `, }, }); @@ -239,24 +240,24 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - export default { - async prerender() { - await new Promise(r => setTimeout(r, 1)); - return ['/', '/about']; - }, - } - `, + export default { + async prerender() { + await new Promise(r => setTimeout(r, 1)); + return ['/', '/about']; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [ - reactRouter() - ], - }); - `, + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, }, }); appFixture = await createAppFixture(fixture); @@ -291,26 +292,26 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - let counter = 1; - export default { - serverBundles: () => "server" + counter++, - async prerender() { - await new Promise(r => setTimeout(r, 1)); - return ['/', '/about']; - }, - } - `, + let counter = 1; + export default { + serverBundles: () => "server" + counter++, + async prerender() { + await new Promise(r => setTimeout(r, 1)); + return ['/', '/about']; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [ - reactRouter() - ], - }); - `, + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, }, }); appFixture = await createAppFixture(fixture); @@ -344,29 +345,29 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - export default { - async prerender({ getStaticPaths }) { - return [...getStaticPaths(), "/a", "/b"]; - }, - } - `, + export default { + async prerender({ getStaticPaths }) { + return [...getStaticPaths(), "/a", "/b"]; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/$slug.tsx": js` - export function loader() { - return null - } - export default function component() { - return null; - } - `, + export function loader() { + return null + } + export default function component() { + return null; + } + `, }, }); appFixture = await createAppFixture(fixture); @@ -443,19 +444,19 @@ test.describe("Prerendering", () => { files: { ...files, "app/routes/text[.txt].tsx": js` - export function loader() { - return new Response("Hello, world"); - } - `, + export function loader() { + return new Response("Hello, world"); + } + `, "app/routes/json[.json].tsx": js` - export function loader() { - return new Response(JSON.stringify({ hello: 'world' }), { - headers: { - 'Content-Type': 'application/json', - } - }); - } - `, + export function loader() { + return new Response(JSON.stringify({ hello: 'world' }), { + headers: { + 'Content-Type': 'application/json', + } + }); + } + `, "app/routes/image[.png].tsx": js` export function loader() { return new Response( @@ -531,24 +532,24 @@ test.describe("Prerendering", () => { files: { ...files, "react-router.config.ts": js` - export default { - async prerender() { - await new Promise(r => setTimeout(r, 1)); - return ['/', 'about']; - }, - } - `, + export default { + async prerender() { + await new Promise(r => setTimeout(r, 1)); + return ['/', 'about']; + }, + } + `, "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [ - reactRouter() - ], - }); - `, + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, }, }); appFixture = await createAppFixture(fixture); @@ -590,36 +591,36 @@ test.describe("Prerendering", () => { prerender: ["/", "/about"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/about.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return "ABOUT-" + request.headers.has('X-React-Router-Prerender'); - } - - export default function Comp() { - let data = useLoaderData(); - return

About: {data}

- } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return "ABOUT-" + request.headers.has('X-React-Router-Prerender'); + } + + export default function Comp() { + let data = useLoaderData(); + return

About: {data}

+ } + `, "app/routes/not-prerendered.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return "NOT-PRERENDERED-" + request.headers.has('X-React-Router-Prerender'); - } - - export default function Comp() { - let data = useLoaderData(); - return

Not-Prerendered: {data}

- } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return "NOT-PRERENDERED-" + request.headers.has('X-React-Router-Prerender'); + } + + export default function Comp() { + let data = useLoaderData(); + return

Not-Prerendered: {data}

+ } + `, }, }); appFixture = await createAppFixture(fixture); @@ -646,35 +647,35 @@ test.describe("Prerendering", () => { prerender: ["/", "/about"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/about.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', - // 24999 characters - data: new Array(5000).fill('test').join('-'), - }; - } - - export default function Comp() { - let data = useLoaderData(); - return ( - <> -

Large loader

-

{data.prerendered}

-

{data.data.length}

- - ); - } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return { + prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + // 24999 characters + data: new Array(5000).fill('test').join('-'), + }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

Large loader

+

{data.prerendered}

+

{data.data.length}

+ + ); + } + `, }, }); appFixture = await createAppFixture(fixture); @@ -699,54 +700,54 @@ test.describe("Prerendering", () => { prerender: ["/", "/utf8-prerendered"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/utf8-prerendered.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', - data: "한글 데이터 - UTF-8 문자", - }; - } - - export default function Comp() { - let data = useLoaderData(); - return ( - <> -

UTF-8 Prerendered

-

{data.prerendered}

-

{data.data}

- - ); - } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return { + prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + data: "한글 데이터 - UTF-8 문자", + }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

UTF-8 Prerendered

+

{data.prerendered}

+

{data.data}

+ + ); + } + `, "app/routes/utf8-not-prerendered.tsx": js` - import { useLoaderData } from 'react-router'; - export function loader({ request }) { - return { - prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', - data: "非プリレンダリングデータ - UTF-8文字", - }; - } - - export default function Comp() { - let data = useLoaderData(); - return ( - <> -

UTF-8 Not Prerendered

-

{data.prerendered}

-

{data.data}

- - ); - } - `, + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return { + prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + data: "非プリレンダリングデータ - UTF-8文字", + }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

UTF-8 Not Prerendered

+

{data.prerendered}

+

{data.data}

+ + ); + } + `, }, }); appFixture = await createAppFixture(fixture); @@ -782,47 +783,47 @@ test.describe("Prerendering", () => { prerender: ["/", "/parent", "/parent/child"], }), "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, + export default defineConfig({ + build: { manifest: true }, + plugins: [reactRouter()], + }); + `, "app/routes/parent.tsx": js` - import { Outlet, useLoaderData } from 'react-router'; - export function loader() { - return "PARENT"; - } - export default function Comp() { - let data = useLoaderData(); - return <>

Parent: {data}

- } - `, + import { Outlet, useLoaderData } from 'react-router'; + export function loader() { + return "PARENT"; + } + export default function Comp() { + let data = useLoaderData(); + return <>

Parent: {data}

+ } + `, "app/routes/parent.child.tsx": js` - import { Outlet, useLoaderData } from 'react-router'; - export function loader() { - return "CHILD"; - } - export function HydrateFallback() { - return

Child loading...

- } - export default function Comp() { - let data = useLoaderData(); - return <>

Child: {data}

- } - `, + import { Outlet, useLoaderData } from 'react-router'; + export function loader() { + return "CHILD"; + } + export function HydrateFallback() { + return

Child loading...

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

Child: {data}

+ } + `, "app/routes/parent.child._index.tsx": js` - import { Outlet, useLoaderData } from 'react-router'; - export function clientLoader() { - return "INDEX"; - } - export default function Comp() { - let data = useLoaderData(); - return <>

Index: {data}

- } - `, + import { Outlet, useLoaderData } from 'react-router'; + export function clientLoader() { + return "INDEX"; + } + export default function Comp() { + let data = useLoaderData(); + return <>

Index: {data}

+ } + `, }, }); appFixture = await createAppFixture(fixture); @@ -853,6 +854,12 @@ test.describe("Prerendering", () => { return requests; } + function clearRequests(requests: string[]) { + while (requests.length) { + requests.pop(); + } + } + test("Errors on headers/action functions in any route", async () => { let cwd = await createProject({ "react-router.config.ts": reactRouterConfig({ @@ -1206,43 +1213,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates across SPA/prerender pages when starting from a prerendered page", async ({ @@ -1345,43 +1354,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates across SPA/prerender pages when starting from a SPA page and a root loader exists", async ({ @@ -1496,43 +1507,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates across SPA/prerender pages when starting from a prerendered page and a root loader exists", async ({ @@ -1647,43 +1660,45 @@ test.describe("Prerendering", () => { expect(await (await page.$("[data-page]"))?.innerText()).toBe( "PAGE DATA" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 1" ); + // No revalidation after submission to self + expect(requests).toEqual([]); await app.clickLink("/page2"); await page.waitForSelector("[data-page2]"); expect(await (await page.$("[data-page2]"))?.innerText()).toBe( "PAGE2 DATA" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 1" ); + expect(requests).toEqual([]); await app.clickSubmitButton("/page"); await page.waitForSelector("[data-page-action]"); expect(await (await page.$("[data-page-action]"))?.innerText()).toBe( "PAGE ACTION 2" ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); await app.clickSubmitButton("/page2"); await page.waitForSelector("[data-page2-action]"); expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe( "PAGE2 ACTION 2" ); - - // We should only make this call when navigating to the prerendered route - // 2 calls (no revalidation after submission to self): - // - ✅ Initial navigation - // - ❌ No revalidation after submission to self - // - ✅ After submission back from /page - expect(requests).toEqual(["/page.data", "/page.data"]); + expect(requests).toEqual([]); }); test("Navigates between prerendered parent and child SPA route", async ({ @@ -2241,6 +2256,183 @@ test.describe("Prerendering", () => { expect(requests).toEqual(["/parent/child.data"]); }); + test("Navigates prerender pages when params exist", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: ["/", "/page", "/param/1", "/param/2"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Link, Outlet, Scripts, useNavigation } from "react-router"; + + export function Layout({ children }) { + let navigation = useNavigation(); + return ( + + + + +

{navigation.state}

+ {children} + + + + ); + } + + export default function Root({ loaderData }) { + return + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/page.tsx": js` + export function loader() { + return "PAGE DATA" + } + export default function Page({ loaderData }) { + return

{loaderData}

; + } + `, + "app/routes/param.$id.tsx": js` + export function loader({ params }) { + return params.id; + } + export default function Page({ loaderData }) { + return

Param {loaderData}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); + + await app.clickLink("/page"); + await page.waitForSelector("#navigation-idle"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + // No revalidation since page.data is static + expect(requests).toEqual([]); + + await app.clickLink("/param/1"); + await page.waitForSelector('[data-param="1"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); + expect(requests).toEqual(["/param/1.data"]); + clearRequests(requests); + + await app.clickLink("/param/2"); + await page.waitForSelector('[data-param="2"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 2"); + expect(requests).toEqual(["/param/2.data"]); + clearRequests(requests); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + expect(requests).toEqual(["/page.data"]); + }); + + test("Returns a 404 if navigating to a non-prerendered param value", async ({ + page, + }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: ["/param/1"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Link, Outlet, Scripts, useNavigation } from "react-router"; + + export function Layout({ children }) { + let navigation = useNavigation(); + return ( + + + + +

{navigation.state}

+ {children} + + + + ); + } + + export default function Root({ loaderData }) { + return + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/param.$id.tsx": js` + export function loader({ params }) { + return params.id; + } + export default function Page({ loaderData }) { + return

Param {loaderData}

; + } + + export function ErrorBoundary({ error }) { + return

{error.status}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/param/1"); + await page.waitForSelector('[data-param="1"]'); + expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1"); + expect(requests).toEqual(["/param/1.data"]); + clearRequests(requests); + + await app.clickLink("/param/404"); + await page.waitForSelector('[data-error="404"]'); + expect(requests).toEqual(["/param/404.data"]); + }); + test("Navigates to prerendered parent with clientLoader calling loader", async ({ page, }) => { @@ -2347,5 +2539,47 @@ test.describe("Prerendering", () => { await page.waitForSelector("[data-error]:has-text('404 Not Found')"); expect(requests).toEqual(["/not-found.data"]); }); + + test("Handles redirects in prerendered pages", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: true, + }), + "app/routes/redirect.tsx": js` + import { redirect } from "react-router" + export function loader() { + return redirect('/target', 301); + } + export default function Component() { +

Nope

+ } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + + // Document loads + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + await page.waitForSelector("#target"); + expect(requests).toEqual([]); + + // Client side navigations + await app.goto("/", true); + app.clickLink("/redirect"); + await page.waitForSelector("#target"); + expect(requests).toEqual(["/redirect.data"]); + }); }); }); diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 12ea017b71..6128229996 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -5,7 +5,13 @@ import { expect } from "@playwright/test"; import { normalizePath } from "vite"; import dedent from "dedent"; -import { build, test, createProject } from "./helpers/vite.js"; +import { + build, + test, + createProject, + viteMajorTemplates, + viteConfig, +} from "./helpers/vite.js"; const js = String.raw; @@ -131,124 +137,121 @@ const files = { ], } `), - "vite.config.ts": dedent(js` - import { reactRouter } from "@react-router/dev/vite"; - - export default { - build: { - assetsDir: "custom-assets-dir", - }, - plugins: [reactRouter()], - } - `), + "vite.config.ts": await viteConfig.basic({ + assetsDir: "custom-assets-dir", + }), }; -test("Vite / presets", async () => { - let cwd = await createProject(files); - let { status, stderr } = build({ cwd }); - expect(stderr.toString()).toBeFalsy(); - expect(status).toBe(0); - - function pathStartsWithCwd(pathname: string) { - return normalizePath(pathname).startsWith(normalizePath(cwd)); - } - - function relativeToCwd(pathname: string) { - return normalizePath(path.relative(cwd, pathname)); - } - - let buildEndArgsMeta: any = await import( - URL.pathToFileURL(path.join(cwd, "BUILD_END_META.js")).href - ); - - let { reactRouterConfig } = buildEndArgsMeta; - - // Smoke test Vite config - expect(buildEndArgsMeta.assetsDir).toBe("custom-assets-dir"); - - // Before rewriting to relative paths, assert that paths are absolute within cwd - expect(pathStartsWithCwd(reactRouterConfig.buildDirectory)).toBe(true); - - // Rewrite path args to be relative and normalized for snapshot test - reactRouterConfig.buildDirectory = relativeToCwd( - reactRouterConfig.buildDirectory - ); - - // Ensure preset configs are merged in correct order, resulting in the correct build directory - expect(reactRouterConfig.buildDirectory).toBe("build"); - - // Ensure preset config takes lower precedence than user config - expect(reactRouterConfig.serverModuleFormat).toBe("esm"); - - // Ensure `reactRouterConfig` is called with a frozen user config - expect( - JSON.parse( - await fs.readFile( - path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_META.json"), - "utf-8" - ) - ) - ).toEqual({ - reactRouterUserConfigFrozen: true, - }); +test.describe("Vite / presets", async () => { + viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + test(templateDisplayName, async () => { + let cwd = await createProject(files, templateName); + let { status, stderr } = build({ cwd }); + expect(stderr.toString()).toBeFalsy(); + expect(status).toBe(0); - // Ensure `reactRouterConfigResolved` is called with a frozen config - expect( - JSON.parse( - await fs.readFile( - path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_RESOLVED_META.json"), - "utf-8" - ) - ) - ).toEqual({ - reactRouterUserConfigFrozen: true, - }); + function pathStartsWithCwd(pathname: string) { + return normalizePath(pathname).startsWith(normalizePath(cwd)); + } + + function relativeToCwd(pathname: string) { + return normalizePath(path.relative(cwd, pathname)); + } + + let buildEndArgsMeta: any = await import( + URL.pathToFileURL(path.join(cwd, "BUILD_END_META.js")).href + ); + + let { reactRouterConfig } = buildEndArgsMeta; + + // Smoke test Vite config + expect(buildEndArgsMeta.assetsDir).toBe("custom-assets-dir"); + + // Before rewriting to relative paths, assert that paths are absolute within cwd + expect(pathStartsWithCwd(reactRouterConfig.buildDirectory)).toBe(true); + + // Rewrite path args to be relative and normalized for snapshot test + reactRouterConfig.buildDirectory = relativeToCwd( + reactRouterConfig.buildDirectory + ); + + // Ensure preset configs are merged in correct order, resulting in the correct build directory + expect(reactRouterConfig.buildDirectory).toBe("build"); + + // Ensure preset config takes lower precedence than user config + expect(reactRouterConfig.serverModuleFormat).toBe("esm"); + + // Ensure `reactRouterConfig` is called with a frozen user config + expect( + JSON.parse( + await fs.readFile( + path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_META.json"), + "utf-8" + ) + ) + ).toEqual({ + reactRouterUserConfigFrozen: true, + }); + + // Ensure `reactRouterConfigResolved` is called with a frozen config + expect( + JSON.parse( + await fs.readFile( + path.join(cwd, "PRESET_REACT_ROUTER_CONFIG_RESOLVED_META.json"), + "utf-8" + ) + ) + ).toEqual({ + reactRouterUserConfigFrozen: true, + }); + + // Snapshot the buildEnd args keys + expect(buildEndArgsMeta.keys).toEqual([ + "buildManifest", + "reactRouterConfig", + "viteConfig", + ]); + + // Smoke test the resolved config + expect(Object.keys(reactRouterConfig)).toEqual([ + "appDirectory", + "basename", + "buildDirectory", + "buildEnd", + "future", + "prerender", + "routes", + "serverBuildFile", + "serverBundles", + "serverModuleFormat", + "ssr", + ]); - // Snapshot the buildEnd args keys - expect(buildEndArgsMeta.keys).toEqual([ - "buildManifest", - "reactRouterConfig", - "viteConfig", - ]); - - // Smoke test the resolved config - expect(Object.keys(reactRouterConfig)).toEqual([ - "appDirectory", - "basename", - "buildDirectory", - "buildEnd", - "future", - "prerender", - "routes", - "serverBuildFile", - "serverBundles", - "serverModuleFormat", - "ssr", - ]); - - // Ensure we get a valid build manifest - expect(buildEndArgsMeta.buildManifest).toEqual({ - routeIdToServerBundleId: { - "routes/_index": "preset-server-bundle-id", - }, - routes: { - root: { - file: "app/root.tsx", - id: "root", - path: "", - }, - "routes/_index": { - file: "app/routes/_index.tsx", - id: "routes/_index", - index: true, - parentId: "root", - }, - }, - serverBundles: { - "preset-server-bundle-id": { - file: "build/server/preset-server-bundle-id/index.js", - id: "preset-server-bundle-id", - }, - }, + // Ensure we get a valid build manifest + expect(buildEndArgsMeta.buildManifest).toEqual({ + routeIdToServerBundleId: { + "routes/_index": "preset-server-bundle-id", + }, + routes: { + root: { + file: "app/root.tsx", + id: "root", + path: "", + }, + "routes/_index": { + file: "app/routes/_index.tsx", + id: "routes/_index", + index: true, + parentId: "root", + }, + }, + serverBundles: { + "preset-server-bundle-id": { + file: "build/server/preset-server-bundle-id/index.js", + id: "preset-server-bundle-id", + }, + }, + }); + }); }); }); diff --git a/integration/vite-route-exports-modified-offscreen-test.ts b/integration/vite-route-exports-modified-offscreen-test.ts index 6ee7562998..56d5d50894 100644 --- a/integration/vite-route-exports-modified-offscreen-test.ts +++ b/integration/vite-route-exports-modified-offscreen-test.ts @@ -76,6 +76,8 @@ test.describe(async () => { originalContents = contents; return contents.replace(/export const loader.*/, ""); }); + // Give the server time to pick the manifest change + await new Promise((resolve) => setTimeout(resolve, 200)); // After browser reload, client should be aware that there's no loader on the other route if (browserName === "webkit") { @@ -83,12 +85,15 @@ test.describe(async () => { // Otherwise browser doesn't seem to fetch new manifest probably due to caching. page = await context.newPage(); } - await page.goto(`http://localhost:${port}`, { waitUntil: "networkidle" }); - await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); - await page.getByRole("link", { name: "/other" }).click(); - await expect(page.locator("[data-loader-data]")).toHaveText( - "loaderData = null" - ); + // In case the earlier wait wasn't enough, let the test try again + await expect(async () => { + await page.goto(`http://localhost:${port}`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + await page.getByRole("link", { name: "/other" }).click(); + await expect(page.locator("[data-loader-data]")).toHaveText( + "loaderData = null" + ); + }).toPass(); expect(pageErrors).toEqual([]); // Revert route to original state to check HMR works and to ensure the diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index f2389a1fe9..03f4e9eaf2 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,9 @@ # `create-react-router` +## 7.5.1 + +_No changes_ + ## 7.5.0 _No changes_ diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index 3b9eb35514..71cfef4b9a 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -1,6 +1,6 @@ { "name": "create-react-router", - "version": "7.5.0", + "version": "7.5.1", "description": "Create a new React Router app", "homepage": "https://reactrouter.com/", "bugs": { diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index d1fd97f236..e780924ea8 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1` + - `@react-router/node@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index 338b6b5af4..8fae1dd38b 100644 --- a/packages/react-router-architect/package.json +++ b/packages/react-router-architect/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/architect", - "version": "7.5.0", + "version": "7.5.1", "description": "Architect server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md index 669bccf158..f296000790 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index 6c37a6e3de..04032c2910 100644 --- a/packages/react-router-cloudflare/package.json +++ b/packages/react-router-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/cloudflare", - "version": "7.5.0", + "version": "7.5.1", "description": "Cloudflare platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index c4c6cb6589..47a840a414 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,15 @@ # `@react-router/dev` +## 7.5.1 + +### Patch Changes + +- Fix prerendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) +- Updated dependencies: + - `react-router@7.5.1` + - `@react-router/node@7.5.1` + - `@react-router/serve@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 28ba35bc3b..fe8ddd6cae 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/dev", - "version": "7.5.0", + "version": "7.5.1", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com/", "bugs": { diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 96b51e1711..66c24acfd9 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -2761,7 +2761,8 @@ async function prerenderData( let response = await handler(request); let data = await response.text(); - if (response.status !== 200) { + // 202 is used for `.data` redirects + if (response.status !== 200 && response.status !== 202) { throw new Error( `Prerender (data): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` ` + @@ -2780,6 +2781,8 @@ async function prerenderData( return data; } +let redirectStatusCodes = new Set([301, 302, 303, 307, 308]); + async function prerenderRoute( handler: RequestHandler, prerenderPath: string, @@ -2796,7 +2799,29 @@ async function prerenderRoute( let response = await handler(request); let html = await response.text(); - if (response.status !== 200) { + if (redirectStatusCodes.has(response.status)) { + // This isn't ideal but gets the job done as a fallback if the user can't + // implement proper redirects via .htaccess or something else. This is the + // approach used by Astro as well so there's some precedent. + // https://github.com/withastro/roadmap/issues/466 + // https://github.com/withastro/astro/blob/main/packages/astro/src/core/routing/3xx.ts + let location = response.headers.get("Location"); + // A short delay causes Google to interpret the redirect as temporary. + // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh + let delay = response.status === 302 ? 2 : 0; + html = ` + +Redirecting to: ${location} + + + + + + Redirecting from ${normalizedPath} to ${location} + + +`; + } else if (response.status !== 200) { throw new Error( `Prerender (html): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + @@ -3383,29 +3408,46 @@ export async function getEnvironmentOptionsResolvers( }: { viteUserConfig: Vite.UserConfig; }): EnvironmentOptions { + // This is a workaround for type errors when running in vite-ecosystem-ci + // against rolldown-vite since "preserveEntrySignatures" is not yet + // supported. We're doing this instead of using `ts-ignore` so we're still + // type checking against regular Vite build options. Once it's supported, + // this custom type can be removed and the build config can be inlined. + type RollupOptionsWithPreserveEntrySignatures = + Vite.BuildOptions["rollupOptions"] extends { + preserveEntrySignatures: any; + } + ? Vite.BuildOptions["rollupOptions"] + : Vite.BuildOptions["rollupOptions"] & { + // We hard-code the one value we're using. If it's not valid in + // Rollup, the build will fail. + preserveEntrySignatures: "exports-only"; + }; + const rollupOptions: RollupOptionsWithPreserveEntrySignatures = { + preserveEntrySignatures: "exports-only", + // Silence Rollup "use client" warnings + // Adapted from https://github.com/vitejs/vite-plugin-react/pull/144 + onwarn(warning, defaultHandler) { + if ( + warning.code === "MODULE_LEVEL_DIRECTIVE" && + warning.message.includes("use client") + ) { + return; + } + let userHandler = viteUserConfig.build?.rollupOptions?.onwarn; + if (userHandler) { + userHandler(warning, defaultHandler); + } else { + defaultHandler(warning); + } + }, + }; + return { build: { cssMinify: viteUserConfig.build?.cssMinify ?? true, manifest: true, // The manifest is enabled for all builds to detect SSR-only assets - rollupOptions: { - preserveEntrySignatures: "exports-only", - // Silence Rollup "use client" warnings - // Adapted from https://github.com/vitejs/vite-plugin-react/pull/144 - onwarn(warning, defaultHandler) { - if ( - warning.code === "MODULE_LEVEL_DIRECTIVE" && - warning.message.includes("use client") - ) { - return; - } - let userHandler = viteUserConfig.build?.rollupOptions?.onwarn; - if (userHandler) { - userHandler(warning, defaultHandler); - } else { - defaultHandler(warning); - } - }, - }, + rollupOptions, }, }; } diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 3609e587e4..686451a0dc 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index 5526d99435..2ad165215b 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "7.5.0", + "version": "7.5.1", "description": "Declarative routing for React web applications", "keywords": [ "react", diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md index cc01059c42..7e4dd0cb93 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1` + - `@react-router/node@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index 1daecbcba3..6d619406d8 100644 --- a/packages/react-router-express/package.json +++ b/packages/react-router-express/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/express", - "version": "7.5.0", + "version": "7.5.1", "description": "Express server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md index f7b9bfad12..a951bf9172 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/fs-routes` +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index 4080e7d819..2a6d3d8cf0 100644 --- a/packages/react-router-fs-routes/package.json +++ b/packages/react-router-fs-routes/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/fs-routes", - "version": "7.5.0", + "version": "7.5.1", "description": "File system routing conventions for React Router, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md index 4833d1984f..6c94b7bf42 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index 7971c7196a..1148620eb7 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/node", - "version": "7.5.0", + "version": "7.5.1", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index f3886ffc33..aab264a2a7 100644 --- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md +++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/remix-config-routes-adapter` +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index 46152b4126..f0ae5a27f3 100644 --- a/packages/react-router-remix-routes-option-adapter/package.json +++ b/packages/react-router-remix-routes-option-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/remix-routes-option-adapter", - "version": "7.5.0", + "version": "7.5.1", "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md index a4e8235eec..28b5e311e7 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## 7.5.1 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.5.1` + - `@react-router/node@7.5.1` + - `@react-router/express@7.5.1` + ## 7.5.0 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index 7dead65971..626f1a8d35 100644 --- a/packages/react-router-serve/package.json +++ b/packages/react-router-serve/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/serve", - "version": "7.5.0", + "version": "7.5.1", "description": "Production application server for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 0a70cb7365..521ca6cb52 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,47 @@ # `react-router` +## 7.5.1 + +### Patch Changes + +- Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route ([#13253](https://github.com/remix-run/react-router/pull/13253)) + +- When using the object-based `route.lazy` API, the `HydrateFallback` and `hydrateFallbackElement` properties are now skipped when lazy loading routes after hydration. ([#13376](https://github.com/remix-run/react-router/pull/13376)) + + If you move the code for these properties into a separate file, you can use this optimization to avoid downloading unused hydration code. For example: + + ```ts + createBrowserRouter([ + { + path: "/show/:showId", + lazy: { + loader: async () => (await import("./show.loader.js")).loader, + Component: async () => (await import("./show.component.js")).Component, + HydrateFallback: async () => + (await import("./show.hydrate-fallback.js")).HydrateFallback, + }, + }, + ]); + ``` + +- Properly revalidate prerendered paths when param values change ([#13380](https://github.com/remix-run/react-router/pull/13380)) + +- UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations ([#13395](https://github.com/remix-run/react-router/pull/13395)) + +- UNSTABLE: Add better error messaging when `getLoadContext` is not updated to return a `Map`" ([#13242](https://github.com/remix-run/react-router/pull/13242)) + +- Do not automatically add `null` to `staticHandler.query()` `context.loaderData` if routes do not have loaders ([#13223](https://github.com/remix-run/react-router/pull/13223)) + + - This was a Remix v2 implementation detail inadvertently left in for React Router v7 + - Now that we allow returning `undefined` from loaders, our prior check of `loaderData[routeId] !== undefined` was no longer sufficient and was changed to a `routeId in loaderData` check - these `null` values can cause issues for this new check + - ⚠️ This could be a "breaking bug fix" for you if you are doing manual SSR with `createStaticHandler()`/``, and using `context.loaderData` to control `` hydration behavior on the client + +- Fix prerendering when a loader returns a redirect ([#13365](https://github.com/remix-run/react-router/pull/13365)) + +- UNSTABLE: Update context type for `LoaderFunctionArgs`/`ActionFunctionArgs` when middleware is enabled ([#13381](https://github.com/remix-run/react-router/pull/13381)) + +- Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy` ([#13253](https://github.com/remix-run/react-router/pull/13253)) + ## 7.5.0 ### Minor Changes diff --git a/packages/react-router/__tests__/dom/data-static-router-test.tsx b/packages/react-router/__tests__/dom/data-static-router-test.tsx index 9a738d1a90..e7a7a2f6cb 100644 --- a/packages/react-router/__tests__/dom/data-static-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-static-router-test.tsx @@ -901,10 +901,7 @@ describe("A ", () => { let expectedJsonString = JSON.stringify( JSON.stringify({ - loaderData: { - 0: null, - "0-0": null, - }, + loaderData: {}, actionData: null, errors: null, }) diff --git a/packages/react-router/__tests__/router/data-strategy-test.ts b/packages/react-router/__tests__/router/data-strategy-test.ts index 4ef1cd2b98..eb385fe4ae 100644 --- a/packages/react-router/__tests__/router/data-strategy-test.ts +++ b/packages/react-router/__tests__/router/data-strategy-test.ts @@ -5,7 +5,7 @@ import type { } from "../../lib/router/utils"; import { createDeferred, - createLazyStub, + createAsyncStub, setup, } from "./utils/data-router-setup"; import { createFormData, tick } from "./utils/utils"; @@ -99,10 +99,8 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub: lazyJsonStub, lazyDeferred: lazyJsonDeferred } = - createLazyStub(); - let { lazyStub: lazyTextStub, lazyDeferred: lazyTextDeferred } = - createLazyStub(); + let [lazyJson, lazyJsonDeferred] = createAsyncStub(); + let [lazyText, lazyTextDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -111,12 +109,12 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyJsonStub, + lazy: lazyJson, children: [ { id: "text", index: true, - lazy: lazyTextStub, + lazy: lazyText, }, ], }, @@ -219,7 +217,7 @@ describe("router dataStrategy", () => { }); it("should allow custom implementations to override default behavior with lazy", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -228,7 +226,7 @@ describe("router dataStrategy", () => { { id: "test", path: "/test", - lazy: lazyStub, + lazy, }, ], async dataStrategy({ matches }) { @@ -374,7 +372,7 @@ describe("router dataStrategy", () => { }); it("does not require resolve to be called if a match is not being loaded", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -389,7 +387,7 @@ describe("router dataStrategy", () => { { id: "child", path: "child", - lazy: lazyStub, + lazy, }, ], }, @@ -451,7 +449,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ); }); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -468,7 +466,7 @@ describe("router dataStrategy", () => { { id: "child", path: "child", - lazy: lazyStub, + lazy, }, ], }, @@ -579,6 +577,92 @@ describe("router dataStrategy", () => { child: "CHILD", }); }); + + it("does not short circuit when there are no matchesToLoad", async () => { + let dataStrategy = mockDataStrategy(async ({ matches }) => { + let results = await Promise.all( + matches.map((m) => m.resolve((handler) => handler())) + ); + // Don't use keyedResults since it checks for shouldLoad and this test + // is always loading + return results.reduce( + (acc, r, i) => Object.assign(acc, { [matches[i].route.id]: r }), + {} + ); + }); + let t = setup({ + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "/parent", + loader: true, + children: [ + { + id: "child", + path: "child", + loader: true, + }, + ], + }, + ], + dataStrategy, + }); + + let A = await t.navigate("/parent"); + await A.loaders.parent.resolve("PARENT1"); + expect(A.loaders.parent.stub).toHaveBeenCalled(); + expect(t.router.state.loaderData).toEqual({ + parent: "PARENT1", + }); + expect(dataStrategy.mock.calls[0][0].matches).toEqual([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "parent", + }), + }), + ]); + + let B = await t.navigate("/parent/child"); + await B.loaders.parent.resolve("PARENT2"); + await B.loaders.child.resolve("CHILD"); + expect(B.loaders.parent.stub).toHaveBeenCalled(); + expect(B.loaders.child.stub).toHaveBeenCalled(); + expect(t.router.state.loaderData).toEqual({ + parent: "PARENT2", + child: "CHILD", + }); + expect(dataStrategy.mock.calls[1][0].matches).toEqual([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "parent", + }), + }), + expect.objectContaining({ + route: expect.objectContaining({ + id: "child", + }), + }), + ]); + + let C = await t.navigate("/parent"); + await C.loaders.parent.resolve("PARENT3"); + expect(C.loaders.parent.stub).toHaveBeenCalled(); + expect(t.router.state.loaderData).toEqual({ + parent: "PARENT3", + }); + expect(dataStrategy.mock.calls[2][0].matches).toEqual([ + expect.objectContaining({ + route: expect.objectContaining({ + id: "parent", + }), + }), + ]); + + expect(dataStrategy).toHaveBeenCalledTimes(3); + }); }); describe("actions", () => { @@ -632,7 +716,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -641,7 +725,7 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyStub, + lazy, }, ], dataStrategy, @@ -721,7 +805,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -730,7 +814,7 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyStub, + lazy, }, ], dataStrategy, @@ -808,7 +892,7 @@ describe("router dataStrategy", () => { keyedResults(matches, results) ) ); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -817,7 +901,7 @@ describe("router dataStrategy", () => { { id: "json", path: "/test", - lazy: lazyStub, + lazy, }, ], dataStrategy, diff --git a/packages/react-router/__tests__/router/lazy-test.ts b/packages/react-router/__tests__/router/lazy-test.ts index 00d931ea55..cc6dcfa7ce 100644 --- a/packages/react-router/__tests__/router/lazy-test.ts +++ b/packages/react-router/__tests__/router/lazy-test.ts @@ -1,5 +1,9 @@ import { createMemoryHistory } from "../../lib/router/history"; import { createRouter, createStaticHandler } from "../../lib/router/router"; +import { + createMemoryRouter, + hydrationRouteProperties, +} from "../../lib/components"; import type { TestNonIndexRouteObject, @@ -8,7 +12,7 @@ import type { import { cleanup, createDeferred, - createLazyStub, + createAsyncStub, setup, } from "./utils/data-router-setup"; import { @@ -52,13 +56,13 @@ describe("lazily loaded route modules", () => { const createBasicLazyFunctionRoutes = (): { routes: TestRouteObject[]; - lazyStub: jest.Mock; + lazy: jest.Mock; lazyDeferred: ReturnType; } => { - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); return { - routes: createBasicLazyRoutes(lazyStub), - lazyStub, + routes: createBasicLazyRoutes(lazy), + lazy, lazyDeferred, }; }; @@ -162,7 +166,7 @@ describe("lazily loaded route modules", () => { it("ignores and warns on unsupported lazy route function properties on router initialization", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderDeferred = createDeferred(); + let loaderDeferred = createDeferred(); let router = createRouter({ routes: [ { @@ -170,7 +174,7 @@ describe("lazily loaded route modules", () => { // @ts-expect-error lazy: async () => { return { - loader: () => lazyLoaderDeferred.promise, + loader: () => loaderDeferred.promise, lazy: async () => { throw new Error("SHOULD NOT BE CALLED"); }, @@ -191,7 +195,7 @@ describe("lazily loaded route modules", () => { router.initialize(); let LOADER_DATA = 123; - await lazyLoaderDeferred.resolve(LOADER_DATA); + await loaderDeferred.resolve(LOADER_DATA); expect(router.state.location.pathname).toBe("/lazy"); expect(router.state.navigation.state).toBe("idle"); @@ -216,13 +220,13 @@ describe("lazily loaded route modules", () => { it("ignores and warns on unsupported lazy route properties on router initialization", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderDeferred = createDeferred(); + let loaderDeferred = createDeferred(); let router = createRouter({ routes: [ { path: "/lazy", lazy: { - loader: () => lazyLoaderDeferred.promise, + loader: () => loaderDeferred.promise, // @ts-expect-error lazy: async () => { throw new Error("SHOULD NOT BE CALLED"); @@ -244,7 +248,7 @@ describe("lazily loaded route modules", () => { let LOADER_DATA = 123; let loader = () => LOADER_DATA; - await lazyLoaderDeferred.resolve(loader); + await loaderDeferred.resolve(loader); expect(router.state.location.pathname).toBe("/lazy"); expect(router.state.navigation.state).toBe("idle"); @@ -323,7 +327,7 @@ describe("lazily loaded route modules", () => { router.initialize(); // Ensure loader is called as soon as it's loaded - let { lazyStub: loader, lazyDeferred: loaderDeferred } = createLazyStub(); + let [loader, loaderDeferred] = createAsyncStub(); await lazyLoaderDeferred.resolve(loader); expect(loader).toHaveBeenCalledTimes(1); expect(router.state.initialized).toBe(false); @@ -346,14 +350,14 @@ describe("lazily loaded route modules", () => { describe("happy path", () => { it("fetches lazy route functions on loading navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); lazyDeferred.resolve({ @@ -361,7 +365,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await loaderDeferred.resolve("LAZY LOADER"); @@ -370,14 +374,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on loading navigation", async () => { - let { lazyStub: lazyLoader, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyAction, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ loader: lazyLoader, action: lazyAction, @@ -391,7 +393,7 @@ describe("lazily loaded route modules", () => { expect(lazyLoader).toHaveBeenCalledTimes(1); // Ensure loader is called as soon as it's loaded - let { lazyStub: loader, lazyDeferred: loaderDeferred } = createLazyStub(); + let [loader, loaderDeferred] = createAsyncStub(); await lazyLoaderDeferred.resolve(loader); expect(loader).toHaveBeenCalledTimes(1); expect(t.router.state.location.pathname).toBe("/"); @@ -417,30 +419,28 @@ describe("lazily loaded route modules", () => { }); it("ignores falsy lazy route properties on loading navigation", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - await lazyDeferred.resolve(null); + await lazyLoaderDeferred.resolve(null); expect(t.router.state.matches[0].route.loader).toBeUndefined(); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.loaderData).toEqual({}); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("fetches lazy route functions on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -448,7 +448,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); let loaderDeferred = createDeferred(); @@ -477,21 +477,19 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on submission navigation", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - action: lazyActionStub, + loader: lazyLoader, + action: lazyAction, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -499,11 +497,11 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - let { lazyStub: action, lazyDeferred: actionDeferred } = createLazyStub(); - let { lazyStub: loader, lazyDeferred: loaderDeferred } = createLazyStub(); + let [action, actionDeferred] = createAsyncStub(); + let [loader, loaderDeferred] = createAsyncStub(); // Ensure action is called as soon as it's loaded await lazyActionDeferred.resolve(action); @@ -531,22 +529,20 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("ignores falsy lazy route properties on submission navigation", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - action: lazyActionStub, + loader: lazyLoader, + action: lazyAction, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -554,8 +550,8 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); await lazyLoaderDeferred.resolve(undefined); await lazyActionDeferred.resolve(null); @@ -563,21 +559,84 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.actionData).toEqual(null); expect(t.router.state.loaderData).toEqual({}); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(t.router.state.matches[0].route.loader).toBeUndefined(); expect(t.router.state.matches[0].route.action).toBeUndefined(); }); + it("only resolves lazy hydration route properties on hydration", async () => { + let [lazyLoaderForHydration, lazyLoaderDeferredForHydration] = + createAsyncStub(); + let [lazyLoaderForNavigation, lazyLoaderDeferredForNavigation] = + createAsyncStub(); + let [ + lazyHydrateFallbackForHydration, + lazyHydrateFallbackDeferredForHydration, + ] = createAsyncStub(); + let [ + lazyHydrateFallbackElementForHydration, + lazyHydrateFallbackElementDeferredForHydration, + ] = createAsyncStub(); + let lazyHydrateFallbackForNavigation = jest.fn(async () => null); + let lazyHydrateFallbackElementForNavigation = jest.fn(async () => null); + let router = createMemoryRouter( + [ + { + path: "/hydration", + lazy: { + HydrateFallback: lazyHydrateFallbackForHydration, + hydrateFallbackElement: lazyHydrateFallbackElementForHydration, + loader: lazyLoaderForHydration, + }, + }, + { + path: "/navigation", + lazy: { + HydrateFallback: lazyHydrateFallbackForNavigation, + hydrateFallbackElement: lazyHydrateFallbackElementForNavigation, + loader: lazyLoaderForNavigation, + }, + }, + ], + { + initialEntries: ["/hydration"], + } + ); + expect(router.state.initialized).toBe(false); + + expect(lazyHydrateFallbackForHydration).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallbackElementForHydration).toHaveBeenCalledTimes(1); + expect(lazyLoaderForHydration).toHaveBeenCalledTimes(1); + await lazyHydrateFallbackDeferredForHydration.resolve(null); + await lazyHydrateFallbackElementDeferredForHydration.resolve(null); + await lazyLoaderDeferredForHydration.resolve(null); + + expect(router.state.location.pathname).toBe("/hydration"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.initialized).toBe(true); + + let navigationPromise = router.navigate("/navigation"); + expect(router.state.location.pathname).toBe("/hydration"); + expect(router.state.navigation.state).toBe("loading"); + expect(lazyHydrateFallbackForNavigation).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElementForNavigation).not.toHaveBeenCalled(); + expect(lazyLoaderForNavigation).toHaveBeenCalledTimes(1); + await lazyLoaderDeferredForNavigation.resolve(null); + await navigationPromise; + expect(router.state.location.pathname).toBe("/navigation"); + expect(router.state.navigation.state).toBe("idle"); + }); + it("fetches lazy route functions on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); lazyDeferred.resolve({ @@ -589,37 +648,69 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on fetcher.load", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); + let t = setup({ routes }); + expect(lazyLoader).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("http://github.com/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + expect(lazyLoader).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDeferred.resolve("LAZY LOADER"); + expect(t.fetchers[key].state).toBe("idle"); + expect(t.fetchers[key].data).toBe("LAZY LOADER"); + + expect(lazyLoader).toHaveBeenCalledTimes(1); + }); + + it("skips lazy hydration route properties on fetcher.load", async () => { + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let lazyHydrateFallback = jest.fn(async () => null); + let lazyHydrateFallbackElement = jest.fn(async () => null); let routes = createBasicLazyRoutes({ - loader: lazyStub, + loader: lazyLoader, + // @ts-expect-error + HydrateFallback: lazyHydrateFallback, + hydrateFallbackElement: lazyHydrateFallbackElement, }); - let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + let t = setup({ routes, hydrationRouteProperties }); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); let loaderDeferred = createDeferred(); - lazyDeferred.resolve(() => loaderDeferred.promise); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.resolve("LAZY LOADER"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); }); it("fetches lazy route functions on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -627,7 +718,7 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); lazyDeferred.resolve({ @@ -639,19 +730,56 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key]?.state).toBe("idle"); expect(t.fetchers[key]?.data).toBe("LAZY ACTION"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("resolves lazy route properties on fetcher.submit", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ + loader: lazyLoader, + action: lazyAction, + }); + let t = setup({ routes }); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("http://github.com/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + let loaderDeferred = createDeferred(); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + lazyActionDeferred.resolve(() => actionDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + + await actionDeferred.resolve("LAZY ACTION"); + expect(t.fetchers[key]?.state).toBe("idle"); + expect(t.fetchers[key]?.data).toBe("LAZY ACTION"); + + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + }); + + it("skips lazy hydration route properties on fetcher.submit", async () => { + let [lazyLoaderStub, lazyLoaderDeferred] = createAsyncStub(); + let [lazyActionStub, lazyActionDeferred] = createAsyncStub(); + let lazyHydrateFallback = jest.fn(async () => null); + let lazyHydrateFallbackElement = jest.fn(async () => null); let routes = createBasicLazyRoutes({ loader: lazyLoaderStub, action: lazyActionStub, + // @ts-expect-error + HydrateFallback: lazyHydrateFallback, + hydrateFallbackElement: lazyHydrateFallbackElement, }); - let t = setup({ routes }); + let t = setup({ routes, hydrationRouteProperties }); expect(lazyLoaderStub).not.toHaveBeenCalled(); expect(lazyActionStub).not.toHaveBeenCalled(); @@ -663,6 +791,8 @@ describe("lazily loaded route modules", () => { expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); expect(lazyLoaderStub).toHaveBeenCalledTimes(1); expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); let actionDeferred = createDeferred(); let loaderDeferred = createDeferred(); @@ -676,6 +806,8 @@ describe("lazily loaded route modules", () => { expect(lazyLoaderStub).toHaveBeenCalledTimes(1); expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyHydrateFallback).not.toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).not.toHaveBeenCalled(); }); it("fetches lazy route functions on staticHandler.query()", async () => { @@ -763,19 +895,48 @@ describe("lazily loaded route modules", () => { let data = await response.json(); expect(data).toEqual({ value: "LAZY LOADER" }); }); + + it("resolves lazy hydration route properties on staticHandler.queryRoute()", async () => { + let lazyHydrateFallback = jest.fn(async () => null); + let lazyHydrateFallbackElement = jest.fn(async () => null); + let { queryRoute } = createStaticHandler( + [ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return () => Response.json({ value: "LAZY LOADER" }); + }, + // @ts-expect-error + HydrateFallback: lazyHydrateFallback, + hydrateFallbackElement: lazyHydrateFallbackElement, + }, + }, + ], + { hydrationRouteProperties } + ); + + let response = await queryRoute(createRequest("/lazy")); + let data = await response.json(); + expect(data).toEqual({ value: "LAZY LOADER" }); + expect(lazyHydrateFallback).toHaveBeenCalled(); + expect(lazyHydrateFallbackElement).toHaveBeenCalled(); + }); }); describe("statically defined fields", () => { it("prefers statically defined loader over lazily defined loader via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { id: "lazy", path: "/lazy", loader: true, - lazy: lazyStub, + lazy, }, ], }); @@ -785,12 +946,10 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("loading"); // Execute in parallel expect(A.loaders.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let loader = jest.fn(() => "LAZY LOADER"); + lazyDeferred.resolve({ loader }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -801,12 +960,12 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.loader).not.toBe(loader); + expect(loader).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -817,7 +976,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -825,7 +984,7 @@ describe("lazily loaded route modules", () => { path: "/lazy", loader: true, lazy: { - loader: lazyStub, + loader: lazyLoader, }, }, ], @@ -836,10 +995,10 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("loading"); // Execute in parallel expect(A.loaders.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - lazyDeferred.resolve(lazyLoaderStub); + let loader = jest.fn(() => "LAZY LOADER"); + lazyLoaderDeferred.resolve(loader); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -850,12 +1009,12 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(0); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.loader).not.toBe(loader); + expect(loader).not.toHaveBeenCalled(); + expect(lazyLoader).toHaveBeenCalledTimes(0); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -866,7 +1025,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined falsy loader via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -874,7 +1033,7 @@ describe("lazily loaded route modules", () => { path: "/lazy", loader: true, lazy: { - loader: lazyStub, + loader: lazyLoader, }, }, ], @@ -885,9 +1044,9 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("loading"); // Execute in parallel expect(A.loaders.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); - lazyDeferred.resolve(null); + lazyLoaderDeferred.resolve(null); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -898,11 +1057,11 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.loader).toBeInstanceOf(Function); - expect(lazyStub).toHaveBeenCalledTimes(0); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.loader).toBeInstanceOf(Function); + expect(lazyLoader).toHaveBeenCalledTimes(0); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -913,14 +1072,14 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action over lazily loaded action via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { id: "lazy", path: "/lazy", action: true, - lazy: lazyStub, + lazy, }, ], }); @@ -933,12 +1092,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("submitting"); // Execute in parallel expect(A.actions.lazy.stub).toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); + let lazyAction = jest.fn(() => "LAZY ACTION"); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ - action: lazyActionStub, + action: lazyAction, loader: () => loaderDeferred.promise, }); expect(t.router.state.location.pathname).toBe("/"); @@ -962,12 +1121,12 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(lazyActionStub); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.action).not.toBe(lazyAction); + expect(lazyAction).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -978,10 +1137,8 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action over lazily loaded action via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -989,8 +1146,8 @@ describe("lazily loaded route modules", () => { path: "/lazy", action: true, lazy: { - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }, }, ], @@ -1004,12 +1161,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("submitting"); // Execute in parallel expect(A.actions.lazy.stub).toHaveBeenCalled(); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let actionStub = jest.fn(() => "LAZY ACTION"); + let action = jest.fn(() => "LAZY ACTION"); let loaderDeferred = createDeferred(); - lazyActionDeferred.resolve(actionStub); + lazyActionDeferred.resolve(action); lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1032,13 +1189,13 @@ describe("lazily loaded route modules", () => { lazy: "LAZY LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(actionStub); - expect(actionStub).not.toHaveBeenCalled(); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.action).not.toBe(action); + expect(action).not.toHaveBeenCalled(); + expect(lazyAction).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1049,7 +1206,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action/loader over lazily defined action/loader via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -1057,7 +1214,7 @@ describe("lazily loaded route modules", () => { path: "/lazy", action: true, loader: true, - lazy: lazyStub, + lazy, }, ], }); @@ -1068,14 +1225,11 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, - }); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyDeferred.resolve({ action, loader }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1097,15 +1251,15 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(lazyActionStub); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.action).not.toBe(action); + expect(route.loader).not.toBe(loader); + expect(action).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); expect(consoleWarn).toHaveBeenCalledTimes(2); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1119,10 +1273,8 @@ describe("lazily loaded route modules", () => { it("prefers statically defined action/loader over lazily defined action/loader via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -1131,8 +1283,8 @@ describe("lazily loaded route modules", () => { action: true, loader: true, lazy: { - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }, }, ], @@ -1144,13 +1296,13 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - let actionStub = jest.fn(() => "LAZY ACTION"); - let loaderStub = jest.fn(() => "LAZY LOADER"); - lazyActionDeferred.resolve(actionStub); - lazyLoaderDeferred.resolve(loaderStub); + expect(lazyAction).toHaveBeenCalledTimes(0); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + lazyActionDeferred.resolve(action); + lazyLoaderDeferred.resolve(loader); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1172,16 +1324,16 @@ describe("lazily loaded route modules", () => { lazy: "STATIC LOADER", }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(actionStub); - expect(lazyRoute.loader).not.toBe(loaderStub); - expect(actionStub).not.toHaveBeenCalled(); - expect(loaderStub).not.toHaveBeenCalled(); - expect(lazyActionStub).toHaveBeenCalledTimes(0); - expect(lazyLoaderStub).toHaveBeenCalledTimes(0); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.action).toEqual(expect.any(Function)); + expect(route.loader).toEqual(expect.any(Function)); + expect(route.action).not.toBe(action); + expect(route.loader).not.toBe(loader); + expect(action).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); + expect(lazyAction).toHaveBeenCalledTimes(0); + expect(lazyLoader).toHaveBeenCalledTimes(0); expect(consoleWarn).toHaveBeenCalledTimes(2); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1195,14 +1347,14 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy function (staticHandler.query)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); - let lazyStub = jest.fn(async () => { + let lazy = jest.fn(async () => { await tick(); return { - loader: lazyLoaderStub, + loader, }; }); @@ -1214,7 +1366,7 @@ describe("lazily loaded route modules", () => { await tick(); return Response.json({ value: "STATIC LOADER" }); }, - lazy: lazyStub, + lazy, }, ]); @@ -1226,8 +1378,8 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(lazyStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazy).toHaveBeenCalledTimes(1); + expect(loader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1238,13 +1390,13 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy property (staticHandler.query)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let loaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); - let lazyStub = jest.fn(async () => { + let lazyLoader = jest.fn(async () => { await tick(); - return loaderStub; + return loader; }); let { query } = createStaticHandler([ @@ -1256,7 +1408,7 @@ describe("lazily loaded route modules", () => { return Response.json({ value: "STATIC LOADER" }); }, lazy: { - loader: lazyStub, + loader: lazyLoader, }, }, ]); @@ -1269,8 +1421,8 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(lazyStub).not.toHaveBeenCalled(); - expect(loaderStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1281,7 +1433,7 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy function (staticHandler.queryRoute)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); @@ -1297,7 +1449,7 @@ describe("lazily loaded route modules", () => { lazy: async () => { await tick(); return { - loader: lazyLoaderStub, + loader, }; }, }, @@ -1311,7 +1463,7 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1322,13 +1474,13 @@ describe("lazily loaded route modules", () => { it("prefers statically defined loader over lazily defined loader via lazy property (staticHandler.queryRoute)", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let loaderStub = jest.fn(async () => { + let loader = jest.fn(async () => { await tick(); return Response.json({ value: "LAZY LOADER" }); }); - let lazyLoaderStub = jest.fn(async () => { + let lazyLoader = jest.fn(async () => { await tick(); - return loaderStub; + return loader; }); let { query } = createStaticHandler([ @@ -1340,7 +1492,7 @@ describe("lazily loaded route modules", () => { return Response.json({ value: "STATIC LOADER" }); }, lazy: { - loader: lazyLoaderStub, + loader: lazyLoader, }, }, ]); @@ -1353,8 +1505,8 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" }, }); - expect(loaderStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledTimes(1); expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -1365,7 +1517,7 @@ describe("lazily loaded route modules", () => { it("handles errors thrown from static loaders before lazy function has completed", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazy, lazyDeferred] = createAsyncStub(); let t = setup({ routes: [ { @@ -1376,13 +1528,13 @@ describe("lazily loaded route modules", () => { id: "lazy", path: "lazy", loader: true, - lazy: lazyStub, + lazy, }, ], }, ], }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let A = await t.navigate("/lazy"); @@ -1400,13 +1552,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: "STATIC LOADER ERROR", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); consoleWarn.mockReset(); }); it("handles errors thrown from static loaders before lazy property has resolved", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let t = setup({ routes: [ { @@ -1418,14 +1571,14 @@ describe("lazily loaded route modules", () => { path: "lazy", loader: true, lazy: { - hasErrorBoundary: lazyStub, + hasErrorBoundary: lazyHasErrorBoundary, }, }, ], }, ], }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); let A = await t.navigate("/lazy"); @@ -1434,21 +1587,22 @@ describe("lazily loaded route modules", () => { // We shouldn't bubble the loader error until after this resolves // so we know if it has a boundary or not - await lazyDeferred.resolve(true); + await lazyHasErrorBoundaryDeferred.resolve(true); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.loaderData).toEqual({}); expect(t.router.state.errors).toEqual({ lazy: "STATIC LOADER ERROR", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); consoleWarn.mockReset(); }); }); it("bubbles errors thrown from static loaders before lazy property has resolved if lazy 'hasErrorBoundary' is falsy", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let { lazyStub, lazyDeferred } = createLazyStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let t = setup({ routes: [ { @@ -1460,14 +1614,14 @@ describe("lazily loaded route modules", () => { path: "lazy", loader: true, lazy: { - hasErrorBoundary: lazyStub, + hasErrorBoundary: lazyHasErrorBoundary, }, }, ], }, ], }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); let A = await t.navigate("/lazy"); @@ -1476,22 +1630,22 @@ describe("lazily loaded route modules", () => { // We shouldn't bubble the loader error until after this resolves // so we know if it has a boundary or not - await lazyDeferred.resolve(null); + await lazyHasErrorBoundaryDeferred.resolve(null); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.loaderData).toEqual({}); expect(t.router.state.errors).toEqual({ root: "STATIC LOADER ERROR", }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); consoleWarn.mockReset(); }); describe("interruptions", () => { it("runs lazily loaded route loader even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); @@ -1501,29 +1655,25 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyDeferred.resolve({ loader }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(1); // Ensure the lazy route object update still happened - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toBe(lazyLoaderStub); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toBe(loader); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route loader even if lazy property is interrupted", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); @@ -1533,24 +1683,24 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve(lazyLoaderStub); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyLoaderDeferred.resolve(loader); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(1); // Ensure the lazy route object update still happened - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toBe(lazyLoaderStub); + let route = findRouteById(t.router.routes, "lazy"); + expect(route.lazy).toBeUndefined(); + expect(route.loader).toBe(loader); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1563,34 +1713,29 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await lazyDeferred.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, - }); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyDeferred.resolve({ action, loader }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toBe(lazyActionStub); - expect(lazyRoute.loader).toBe(lazyLoaderStub); - expect(lazyStub).toHaveBeenCalledTimes(1); + let route = findRouteById(t.router.routes, "lazy"); + expect(action).toHaveBeenCalledTimes(1); + expect(loader).not.toHaveBeenCalled(); + expect(route.lazy).toBeUndefined(); + expect(route.action).toBe(action); + expect(route.loader).toBe(loader); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action even if lazy property is interrupted", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1603,84 +1748,78 @@ describe("lazily loaded route modules", () => { expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - let actionStub = jest.fn(() => "LAZY ACTION"); - let loaderStub = jest.fn(() => "LAZY LOADER"); - await lazyActionDeferred.resolve(actionStub); - await lazyLoaderDeferred.resolve(loaderStub); - - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(actionStub).toHaveBeenCalledTimes(1); - expect(loaderStub).not.toHaveBeenCalled(); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toBe(actionStub); - expect(lazyRoute.loader).toBe(loaderStub); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + let action = jest.fn(() => "LAZY ACTION"); + let loader = jest.fn(() => "LAZY LOADER"); + await lazyActionDeferred.resolve(action); + await lazyLoaderDeferred.resolve(loader); + + let route = findRouteById(t.router.routes, "lazy"); + expect(action).toHaveBeenCalledTimes(1); + expect(loader).not.toHaveBeenCalled(); + expect(route.lazy).toBeUndefined(); + expect(route.action).toBe(action); + expect(route.loader).toBe(loader); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route loader on fetcher.load() even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ loader }); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.resolve("LAZY LOADER"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route loader on fetcher.load() even if lazy property is interrupted", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let loaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve(loaderStub); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyLoaderDeferred.resolve(loader); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.resolve("LAZY LOADER"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(loaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action on fetcher.submit() even if lazy function is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -1688,37 +1827,32 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key, { formMethod: "post", formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let actionDeferred = createDeferred(); - let lazyActionStub = jest.fn(() => actionDeferred.promise); - await lazyDeferred.resolve({ - action: lazyActionStub, - }); + let [action, actionDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ action }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDeferred.resolve("LAZY ACTION"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY ACTION"); - expect(lazyActionStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("runs lazily loaded route action on fetcher.submit() even if lazy property is interrupted", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -1726,48 +1860,44 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key, { formMethod: "post", formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - let actionDeferred = createDeferred(); - let lazyActionStub = jest.fn(() => actionDeferred.promise); - await lazyDeferred.resolve(lazyActionStub); + let [action, actionDeferred] = createAsyncStub(); + await lazyActionDeferred.resolve(action); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDeferred.resolve("LAZY ACTION"); expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY ACTION"); - expect(lazyActionStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function execution on repeated loading navigations", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ loader }); await loaderDeferred.resolve("LAZY LOADER"); @@ -1776,31 +1906,28 @@ describe("lazily loaded route modules", () => { expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy property execution on repeated loading navigations", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve(lazyLoaderStub); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyLoaderDeferred.resolve(loader); await loaderDeferred.resolve("LAZY LOADER"); @@ -1809,14 +1936,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function execution on repeated submission navigations", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1824,7 +1951,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.navigate("/lazy", { formMethod: "post", @@ -1832,16 +1959,11 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let actionDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - let lazyActionStub = jest.fn(() => actionDeferred.promise); - await lazyDeferred.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, - }); + let [action, actionDeferred] = createAsyncStub(); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ action, loader }); await actionDeferred.resolve("LAZY ACTION"); await loaderDeferred.resolve("LAZY LOADER"); @@ -1852,23 +1974,21 @@ describe("lazily loaded route modules", () => { expect(t.router.state.actionData).toEqual({ lazy: "LAZY ACTION" }); expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(lazyActionStub).toHaveBeenCalledTimes(2); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function property on repeated submission navigations", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, - loader: lazyLoaderStub, + action: lazyAction, + loader: lazyLoader, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1876,8 +1996,8 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.navigate("/lazy", { formMethod: "post", @@ -1885,15 +2005,13 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let actionDeferred = createDeferred(); - let loaderStub = jest.fn(() => loaderDeferred.promise); - let actionStub = jest.fn(() => actionDeferred.promise); - await lazyActionDeferred.resolve(actionStub); - await lazyLoaderDeferred.resolve(loaderStub); + let [action, actionDeferred] = createAsyncStub(); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyActionDeferred.resolve(action); + await lazyLoaderDeferred.resolve(loader); await actionDeferred.resolve("LAZY ACTION"); await loaderDeferred.resolve("LAZY LOADER"); @@ -1904,31 +2022,28 @@ describe("lazily loaded route modules", () => { expect(t.router.state.actionData).toEqual({ lazy: "LAZY ACTION" }); expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); - expect(actionStub).toHaveBeenCalledTimes(2); - expect(loaderStub).toHaveBeenCalledTimes(1); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy function execution on repeated fetcher.load calls", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve({ - loader: lazyLoaderStub, - }); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyDeferred.resolve({ loader }); expect(t.fetchers[key].state).toBe("loading"); @@ -1936,30 +2051,27 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazy).toHaveBeenCalledTimes(1); }); it("uses the first-called lazy property execution on repeated fetcher.load calls", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - let loaderDeferred = createDeferred(); - let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); - await lazyDeferred.resolve(lazyLoaderStub); + let [loader, loaderDeferred] = createAsyncStub(); + await lazyLoaderDeferred.resolve(loader); expect(t.fetchers[key].state).toBe("loading"); @@ -1967,8 +2079,8 @@ describe("lazily loaded route modules", () => { expect(t.fetchers[key].state).toBe("idle"); expect(t.fetchers[key].data).toBe("LAZY LOADER"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(2); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledTimes(2); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); }); @@ -2003,7 +2115,7 @@ describe("lazily loaded route modules", () => { }); it("handles errors when failing to resolve lazy route property on initialization", async () => { - let lazyDeferred = createDeferred(); + let lazyLoaderDeferred = createDeferred(); let router = createRouter({ history: createMemoryHistory({ initialEntries: ["/lazy"] }), routes: [ @@ -2016,7 +2128,7 @@ describe("lazily loaded route modules", () => { id: "lazy", path: "lazy", lazy: { - loader: () => lazyDeferred.promise, + loader: () => lazyLoaderDeferred.promise, }, }, ], @@ -2025,7 +2137,7 @@ describe("lazily loaded route modules", () => { }).initialize(); expect(router.state.initialized).toBe(false); - lazyDeferred.reject(new Error("LAZY PROPERTY ERROR")); + lazyLoaderDeferred.reject(new Error("LAZY PROPERTY ERROR")); await tick(); expect(router.state.errors).toEqual({ root: new Error("LAZY PROPERTY ERROR"), @@ -2034,14 +2146,14 @@ describe("lazily loaded route modules", () => { }); it("handles errors when failing to resolve lazy route function on loading navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2051,23 +2163,21 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve lazy route loader property on loading navigation", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY PROPERTY ERROR")); + await lazyLoaderDeferred.reject(new Error("LAZY PROPERTY ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); @@ -2075,14 +2185,12 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY PROPERTY ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve other lazy route properties on loading navigation", async () => { - let { lazyStub: lazyLoader, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { lazyStub: lazyAction, lazyDeferred: lazyActionDeferred } = - createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); let routes = createBasicLazyRoutes({ loader: lazyLoader, action: lazyAction, @@ -2116,14 +2224,14 @@ describe("lazily loaded route modules", () => { }); it("handles loader errors from lazy route functions when the route has an error boundary", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2132,7 +2240,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2140,36 +2248,33 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles loader errors from lazy route properties when the route has an error boundary", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { - lazyStub: lazyHasErrorBoundaryStub, - lazyDeferred: lazyHasErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - hasErrorBoundary: lazyHasErrorBoundaryStub, + loader: lazyLoader, + hasErrorBoundary: lazyHasErrorBoundary, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); await lazyHasErrorBoundaryDeferred.resolve(() => true); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2177,19 +2282,19 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from in lazy route functions when the route does not specify an error boundary", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2205,24 +2310,22 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from in lazy route properties when the route does not specify an error boundary", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); - await lazyDeferred.resolve(() => loaderDeferred.promise); + await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); @@ -2233,18 +2336,18 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from lazy route functions when the route specifies hasErrorBoundary:false", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2261,29 +2364,26 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from lazy route properties when the route specifies hasErrorBoundary:false", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { - lazyStub: lazyHasErrorBoundaryStub, - lazyDeferred: lazyHasErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - hasErrorBoundary: lazyHasErrorBoundaryStub, + loader: lazyLoader, + hasErrorBoundary: lazyHasErrorBoundary, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); @@ -2298,30 +2398,27 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); }); it("bubbles loader errors from lazy route properties when the route specifies hasErrorBoundary:null", async () => { - let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = - createLazyStub(); - let { - lazyStub: lazyHasErrorBoundaryStub, - lazyDeferred: lazyHasErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let [lazyHasErrorBoundary, lazyHasErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - loader: lazyLoaderStub, - hasErrorBoundary: lazyHasErrorBoundaryStub, + loader: lazyLoader, + hasErrorBoundary: lazyHasErrorBoundary, }); let t = setup({ routes }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundary).not.toHaveBeenCalled(); await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); @@ -2336,14 +2433,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundary).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve lazy route functions on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2351,7 +2448,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); @@ -2362,16 +2459,14 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.actionData).toEqual(null); expect(t.router.state.loaderData).toEqual({}); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to resolve lazy route properties on submission navigation", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2379,9 +2474,9 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + await lazyActionDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.location.pathname).toBe("/lazy"); expect(t.router.state.navigation.state).toBe("idle"); @@ -2390,13 +2485,13 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.actionData).toEqual(null); expect(t.router.state.loaderData).toEqual({}); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("handles action errors from lazy route functions on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2404,7 +2499,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2421,22 +2516,19 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles action errors from lazy route properties on submission navigation", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { - lazyStub: lazyErrorBoundaryStub, - lazyDeferred: lazyErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyErrorBoundaryStub, lazyErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, + action: lazyAction, hasErrorBoundary: lazyErrorBoundaryStub, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { @@ -2445,7 +2537,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); @@ -2461,14 +2553,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ lazy: new Error("LAZY ACTION ERROR"), }); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); }); it("bubbles action errors from lazy route functions when the route specifies hasErrorBoundary:false", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -2476,7 +2568,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2493,22 +2585,19 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("bubbles action errors from lazy route properties when the route specifies hasErrorBoundary:false", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { - lazyStub: lazyErrorBoundaryStub, - lazyDeferred: lazyErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyErrorBoundaryStub, lazyErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, + action: lazyAction, hasErrorBoundary: lazyErrorBoundaryStub, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { @@ -2517,7 +2606,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); @@ -2533,23 +2622,20 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); }); it("bubbles action errors from lazy route properties when the route specifies hasErrorBoundary:null", async () => { - let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = - createLazyStub(); - let { - lazyStub: lazyErrorBoundaryStub, - lazyDeferred: lazyErrorBoundaryDeferred, - } = createLazyStub(); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let [lazyErrorBoundaryStub, lazyErrorBoundaryDeferred] = + createAsyncStub(); let routes = createBasicLazyRoutes({ - action: lazyActionStub, + action: lazyAction, hasErrorBoundary: lazyErrorBoundaryStub, }); let t = setup({ routes }); - expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { @@ -2558,7 +2644,7 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); @@ -2574,58 +2660,56 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route functions on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route properties on fetcher.load", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + await lazyLoaderDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("handles loader errors in lazy route functions on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2638,24 +2722,22 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles loader errors in lazy route properties on fetcher.load", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - loader: lazyStub, - }); + let [lazyLoader, lazyLoaderDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ loader: lazyLoader }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyLoader).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); let loaderDeferred = createDeferred(); - await lazyDeferred.resolve(() => loaderDeferred.promise); + await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); @@ -2663,13 +2745,13 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyLoader).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route functions on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2677,23 +2759,21 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles errors when failing to load lazy route properties on fetcher.submit", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2701,20 +2781,20 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); - await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + await lazyActionDeferred.reject(new Error("LAZY FUNCTION ERROR")); expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("handles action errors in lazy route functions on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let { routes, lazy, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazy).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2722,7 +2802,7 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); await lazyDeferred.resolve({ @@ -2736,16 +2816,14 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazy).toHaveBeenCalledTimes(1); }); it("handles action errors in lazy route properties on fetcher.submit", async () => { - let { lazyStub, lazyDeferred } = createLazyStub(); - let routes = createBasicLazyRoutes({ - action: lazyStub, - }); + let [lazyAction, lazyActionDeferred] = createAsyncStub(); + let routes = createBasicLazyRoutes({ action: lazyAction }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyAction).not.toHaveBeenCalled(); let key = "key"; await t.fetch("http://github.com/lazy", key, { @@ -2753,10 +2831,10 @@ describe("lazily loaded route modules", () => { formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); - await lazyDeferred.resolve(() => actionDeferred.promise); + await lazyActionDeferred.resolve(() => actionDeferred.promise); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDeferred.reject(new Error("LAZY ACTION ERROR")); @@ -2765,7 +2843,7 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyAction).toHaveBeenCalledTimes(1); }); it("throws when failing to resolve lazy route functions on staticHandler.query()", async () => { @@ -2852,9 +2930,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); @@ -2891,9 +2967,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ lazy: new Error("LAZY LOADER ERROR"), }); @@ -2928,9 +3002,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); @@ -2967,9 +3039,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); @@ -3006,9 +3076,7 @@ describe("lazily loaded route modules", () => { !(context instanceof Response), "Expected a StaticContext instance" ); - expect(context.loaderData).toEqual({ - root: null, - }); + expect(context.loaderData).toEqual({}); expect(context.errors).toEqual({ root: new Error("LAZY LOADER ERROR"), }); diff --git a/packages/react-router/__tests__/router/ssr-test.ts b/packages/react-router/__tests__/router/ssr-test.ts index 4ef923a368..77faaaba05 100644 --- a/packages/react-router/__tests__/router/ssr-test.ts +++ b/packages/react-router/__tests__/router/ssr-test.ts @@ -186,7 +186,7 @@ describe("ssr", () => { }); }); - it("should fill in null loaderData values for routes without loaders", async () => { + it("should not fill in null loaderData values for routes without loaders", async () => { let { query } = createStaticHandler([ { id: "root", @@ -215,10 +215,7 @@ describe("ssr", () => { let context = await query(createRequest("/none")); expect(context).toMatchObject({ actionData: null, - loaderData: { - root: null, - none: null, - }, + loaderData: {}, errors: null, location: { pathname: "/none" }, }); @@ -228,9 +225,7 @@ describe("ssr", () => { expect(context).toMatchObject({ actionData: null, loaderData: { - root: null, a: "A", - b: null, }, errors: null, location: { pathname: "/a/b" }, diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts index f219cbb58d..b097054049 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -139,6 +139,7 @@ type SetupOpts = { basename?: string; initialEntries?: InitialEntry[]; initialIndex?: number; + hydrationRouteProperties?: string[]; hydrationData?: HydrationState; dataStrategy?: DataStrategyFunction; }; @@ -168,17 +169,14 @@ export function createDeferred() { }; } -export function createLazyStub(): { - lazyStub: jest.Mock; - lazyDeferred: ReturnType; -} { - let lazyDeferred = createDeferred(); - let lazyStub = jest.fn(() => lazyDeferred.promise); +export function createAsyncStub(): [ + asyncStub: jest.Mock, + deferred: ReturnType +] { + let deferred = createDeferred(); + let asyncStub = jest.fn(() => deferred.promise); - return { - lazyStub, - lazyDeferred, - }; + return [asyncStub, deferred]; } export function getFetcherData(router: Router) { @@ -207,6 +205,7 @@ export function setup({ basename, initialEntries, initialIndex, + hydrationRouteProperties, hydrationData, dataStrategy, }: SetupOpts) { @@ -322,6 +321,7 @@ export function setup({ basename, history, routes: enhanceRoutes(routes), + hydrationRouteProperties, hydrationData, window: testWindow, dataStrategy: dataStrategy, diff --git a/packages/react-router/__tests__/server-runtime/server-test.ts b/packages/react-router/__tests__/server-runtime/server-test.ts index 9301f68ee6..fa62752bb8 100644 --- a/packages/react-router/__tests__/server-runtime/server-test.ts +++ b/packages/react-router/__tests__/server-runtime/server-test.ts @@ -2,7 +2,10 @@ * @jest-environment node */ -import type { StaticHandlerContext } from "react-router"; +import { + unstable_createContext, + type StaticHandlerContext, +} from "react-router"; import { createRequestHandler } from "../../lib/server-runtime/server"; import { ServerMode } from "../../lib/server-runtime/mode"; @@ -24,7 +27,7 @@ function spyConsole() { return spy; } -describe.skip("server", () => { +describe("server", () => { let routeId = "root"; let build: ServerBuild = { ssr: true, @@ -72,20 +75,20 @@ describe.skip("server", () => { }); let allowThrough = [ - ["GET", "/"], - ["GET", "/?_data=root"], - ["POST", "/"], - ["POST", "/?_data=root"], - ["PUT", "/"], - ["PUT", "/?_data=root"], - ["DELETE", "/"], - ["DELETE", "/?_data=root"], - ["PATCH", "/"], - ["PATCH", "/?_data=root"], + ["GET", "/", "COMPONENT"], + ["GET", "/_root.data", "LOADER"], + ["POST", "/", "COMPONENT"], + ["POST", "/_root.data", "ACTION"], + ["PUT", "/", "COMPONENT"], + ["PUT", "/_root.data", "ACTION"], + ["DELETE", "/", "COMPONENT"], + ["DELETE", "/_root.data", "ACTION"], + ["PATCH", "/", "COMPONENT"], + ["PATCH", "/_root.data", "ACTION"], ]; it.each(allowThrough)( `allows through %s request to %s`, - async (method, to) => { + async (method, to, expected) => { let handler = createRequestHandler(build); let response = await handler( new Request(`http://localhost:3000${to}`, { @@ -96,11 +99,6 @@ describe.skip("server", () => { expect(response.status).toBe(200); let text = await response.text(); expect(text).toContain(method); - let expected = !to.includes("?_data=root") - ? "COMPONENT" - : method === "GET" - ? "LOADER" - : "ACTION"; expect(text).toContain(expected); expect(spy.console).not.toHaveBeenCalled(); } @@ -116,6 +114,203 @@ describe.skip("server", () => { expect(await response.text()).toBe(""); }); + + it("accepts proper values from getLoadContext (without middleware)", async () => { + let handler = createRequestHandler({ + ssr: true, + entry: { + module: { + default: async (request) => { + return new Response( + `${request.method}, ${request.url} COMPONENT` + ); + }, + }, + }, + routes: { + root: { + id: "root", + path: "", + module: { + loader: ({ context }) => context.foo, + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + root: { + clientActionModule: undefined, + clientLoaderModule: undefined, + clientMiddlewareModule: undefined, + hasAction: true, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: true, + hydrateFallbackModule: undefined, + id: routeId, + module: routeId, + path: "", + }, + }, + entry: { imports: [], module: "" }, + url: "", + version: "", + }, + future: { + unstable_middleware: false, + }, + prerender: [], + publicPath: "/", + assetsBuildDirectory: "/", + isSpaMode: false, + }); + let response = await handler( + new Request("http://localhost:3000/_root.data"), + { + foo: "FOO", + } + ); + + expect(await response.text()).toContain("FOO"); + }); + + it("accepts proper values from getLoadContext (with middleware)", async () => { + let fooContext = unstable_createContext(); + let handler = createRequestHandler({ + ssr: true, + entry: { + module: { + default: async (request) => { + return new Response( + `${request.method}, ${request.url} COMPONENT` + ); + }, + }, + }, + routes: { + root: { + id: "root", + path: "", + module: { + loader: ({ context }) => context.get(fooContext), + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + root: { + clientActionModule: undefined, + clientLoaderModule: undefined, + clientMiddlewareModule: undefined, + hasAction: true, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: true, + hydrateFallbackModule: undefined, + id: routeId, + module: routeId, + path: "", + }, + }, + entry: { imports: [], module: "" }, + url: "", + version: "", + }, + future: { + unstable_middleware: true, + }, + prerender: [], + publicPath: "/", + assetsBuildDirectory: "/", + isSpaMode: false, + }); + let response = await handler( + new Request("http://localhost:3000/_root.data"), + // @ts-expect-error In apps the expected type is handled via the Future interface + new Map([[fooContext, "FOO"]]) + ); + + expect(await response.text()).toContain("FOO"); + }); + + it("errors if an invalid value is returned from getLoadContext (with middleware)", async () => { + let handleErrorSpy = jest.fn(); + let handler = createRequestHandler({ + ssr: true, + entry: { + module: { + handleError: handleErrorSpy, + default: async (request) => { + return new Response( + `${request.method}, ${request.url} COMPONENT` + ); + }, + }, + }, + routes: { + root: { + id: "root", + path: "", + module: { + loader: ({ context }) => context.foo, + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + root: { + clientActionModule: undefined, + clientLoaderModule: undefined, + clientMiddlewareModule: undefined, + hasAction: true, + hasClientAction: false, + hasClientLoader: false, + hasClientMiddleware: false, + hasErrorBoundary: false, + hasLoader: true, + hydrateFallbackModule: undefined, + id: routeId, + module: routeId, + path: "", + }, + }, + entry: { imports: [], module: "" }, + url: "", + version: "", + }, + future: { + unstable_middleware: true, + }, + prerender: [], + publicPath: "/", + assetsBuildDirectory: "/", + isSpaMode: false, + }); + + let response = await handler( + new Request("http://localhost:3000/_root.data"), + { + foo: "FOO", + } + ); + + expect(response.status).toBe(500); + expect(await response.text()).toContain("Unexpected Server Error"); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + [Error: Unable to create initial \`unstable_RouterContextProvider\` instance. Please confirm you are returning an instance of \`Map\` from your \`getLoadContext\` function. + + Error: TypeError: init is not iterable] + `); + handleErrorSpy.mockRestore(); + }); }); }); @@ -1144,10 +1339,7 @@ describe("shared server runtime", () => { let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); expect(context.errors!.root.status).toBe(400); - expect(context.loaderData).toEqual({ - root: null, - "routes/test": null, - }); + expect(context.loaderData).toEqual({}); }); test("thrown action responses bubble up for index routes", async () => { @@ -1191,10 +1383,7 @@ describe("shared server runtime", () => { let context = calls[0][3].staticHandlerContext as StaticHandlerContext; expect(context.errors).toBeTruthy(); expect(context.errors!.root.status).toBe(400); - expect(context.loaderData).toEqual({ - root: null, - "routes/_index": null, - }); + expect(context.loaderData).toEqual({}); }); test("thrown action responses catch deep", async () => { @@ -1240,7 +1429,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/test"].status).toBe(400); expect(context.loaderData).toEqual({ root: "root", - "routes/test": null, }); }); @@ -1287,7 +1475,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/_index"].status).toBe(400); expect(context.loaderData).toEqual({ root: "root", - "routes/_index": null, }); }); @@ -1342,8 +1529,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].data).toBe("action"); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/test": null, }); }); @@ -1398,8 +1583,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].data).toBe("action"); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/index": null, }); }); @@ -1533,10 +1716,7 @@ describe("shared server runtime", () => { expect(context.errors!.root).toBeInstanceOf(Error); expect(context.errors!.root.message).toBe("Unexpected Server Error"); expect(context.errors!.root.stack).toBeUndefined(); - expect(context.loaderData).toEqual({ - root: null, - "routes/test": null, - }); + expect(context.loaderData).toEqual({}); }); test("action errors bubble up for index routes", async () => { @@ -1582,10 +1762,7 @@ describe("shared server runtime", () => { expect(context.errors!.root).toBeInstanceOf(Error); expect(context.errors!.root.message).toBe("Unexpected Server Error"); expect(context.errors!.root.stack).toBeUndefined(); - expect(context.loaderData).toEqual({ - root: null, - "routes/_index": null, - }); + expect(context.loaderData).toEqual({}); }); test("action errors catch deep", async () => { @@ -1635,7 +1812,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/test"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/test": null, }); }); @@ -1686,7 +1862,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/_index"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/_index": null, }); }); @@ -1745,8 +1920,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/test": null, }); }); @@ -1805,8 +1978,6 @@ describe("shared server runtime", () => { expect(context.errors!["routes/__layout"].stack).toBeUndefined(); expect(context.loaderData).toEqual({ root: "root", - "routes/__layout": null, - "routes/__layout/index": null, }); }); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 2fc07c9e73..f72fd36a32 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -317,7 +317,10 @@ export { } from "./lib/context"; /** @internal */ -export { mapRouteProperties as UNSAFE_mapRouteProperties } from "./lib/components"; +export { + hydrationRouteProperties as UNSAFE_hydrationRouteProperties, + mapRouteProperties as UNSAFE_mapRouteProperties, +} from "./lib/components"; /** @internal */ export { FrameworkContext as UNSAFE_FrameworkContext } from "./lib/dom/ssr/components"; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 18f52b93f2..f0a7346e47 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -131,6 +131,11 @@ export function mapRouteProperties(route: RouteObject) { return updates; } +export const hydrationRouteProperties: (keyof RouteObject)[] = [ + "HydrateFallback", + "hydrateFallbackElement", +]; + export interface MemoryRouterOpts { /** * Basename path for the application. @@ -194,6 +199,7 @@ export function createMemoryRouter( }), hydrationData: opts?.hydrationData, routes, + hydrationRouteProperties, mapRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 388820ccd7..ea3dddc7db 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -21,6 +21,7 @@ import { UNSAFE_shouldHydrateRouteLoader as shouldHydrateRouteLoader, UNSAFE_useFogOFWarDiscovery as useFogOFWarDiscovery, UNSAFE_mapRouteProperties as mapRouteProperties, + UNSAFE_hydrationRouteProperties as hydrationRouteProperties, UNSAFE_createClientRoutesWithHMRRevalidationOptOut as createClientRoutesWithHMRRevalidationOptOut, matchRoutes, } from "react-router"; @@ -201,16 +202,29 @@ function createHydratedRouter({ basename: ssrInfo.context.basename, unstable_getContext, hydrationData, + hydrationRouteProperties, mapRouteProperties, future: { unstable_middleware: ssrInfo.context.future.unstable_middleware, }, dataStrategy: getSingleFetchDataStrategy( - ssrInfo.manifest, - ssrInfo.routeModules, + () => router, + (routeId: string) => { + let manifestRoute = ssrInfo!.manifest.routes[routeId]; + invariant(manifestRoute, "Route not found in manifest/routeModules"); + let routeModule = ssrInfo!.routeModules[routeId]; + return { + hasLoader: manifestRoute.hasLoader, + hasClientLoader: manifestRoute.hasClientLoader, + // In some cases the module may not be loaded yet and we don't care + // if it's got shouldRevalidate or not + hasShouldRevalidate: routeModule + ? routeModule.shouldRevalidate != null + : undefined, + }; + }, ssrInfo.context.ssr, - ssrInfo.context.basename, - () => router + ssrInfo.context.basename ), patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( ssrInfo.manifest, diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 50a456ef9f..e280d79691 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -66,7 +66,11 @@ import { mergeRefs, usePrefetchBehavior, } from "./ssr/components"; -import { Router, mapRouteProperties } from "../components"; +import { + Router, + mapRouteProperties, + hydrationRouteProperties, +} from "../components"; import type { RouteObject, NavigateOptions, @@ -186,6 +190,7 @@ export function createBrowserRouter( hydrationData: opts?.hydrationData || parseHydrationData(), routes, mapRouteProperties, + hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, @@ -209,6 +214,7 @@ export function createHashRouter( hydrationData: opts?.hydrationData || parseHydrationData(), routes, mapRouteProperties, + hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 27cb9feb2f..985b3246ac 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -4,12 +4,11 @@ import type { HydrationState } from "../../router/router"; import type { ActionFunctionArgs, LoaderFunctionArgs, - unstable_MiddlewareFunction, RouteManifest, ShouldRevalidateFunction, ShouldRevalidateFunctionArgs, } from "../../router/utils"; -import { ErrorResponseImpl } from "../../router/utils"; +import { ErrorResponseImpl, compilePath } from "../../router/utils"; import type { RouteModule, RouteModules } from "./routeModules"; import { loadRouteModule } from "./routeModules"; import type { FutureConfig } from "./entry"; @@ -312,6 +311,7 @@ export function createClientRoutes( unstable_middleware: routeModule.unstable_clientMiddleware, handle: routeModule.handle, shouldRevalidate: getShouldRevalidateFunction( + dataRoute.path, routeModule, route, ssr, @@ -524,6 +524,7 @@ export function createClientRoutes( shouldRevalidate: async () => { let lazyRoute = await getLazyRoute(); return getShouldRevalidateFunction( + dataRoute.path, lazyRoute, route, ssr, @@ -556,6 +557,7 @@ export function createClientRoutes( } function getShouldRevalidateFunction( + path: string | undefined, route: Partial, manifestRoute: Omit, ssr: boolean, @@ -572,17 +574,29 @@ function getShouldRevalidateFunction( // When prerendering is enabled with `ssr:false`, any `loader` data is // statically generated at build time so if we have a `loader` but not a - // `clientLoader`, we disable revalidation by default since we can't be sure - // if a `.data` file was pre-rendered. If users are somehow re-generating - // updated versions of these on the backend they can still opt-into - // revalidation which will make the `.data` request + // `clientLoader`, we only revalidate if the route's params changed since we + // can't be sure if a `.data` file was pre-rendered otherwise. + // + // I.e., If I have a parent and a child route and I only prerender `/parent`, + // we can't have parent revalidate when going from `/parent -> /parent/child` + // because `/parent/child.data` doesn't exist. + // + // If users are somehow re-generating updated versions of these on the backend + // they can still opt-into revalidation which will make the `.data` request if (!ssr && manifestRoute.hasLoader && !manifestRoute.hasClientLoader) { + let myParams = path ? compilePath(path)[1].map((p) => p.paramName) : []; + const didParamsChange = (opts: ShouldRevalidateFunctionArgs) => + myParams.some((p) => opts.currentParams[p] !== opts.nextParams[p]); + if (route.shouldRevalidate) { let fn = route.shouldRevalidate; return (opts: ShouldRevalidateFunctionArgs) => - fn({ ...opts, defaultShouldRevalidate: false }); + fn({ + ...opts, + defaultShouldRevalidate: didParamsChange(opts), + }); } else { - return () => false; + return (opts: ShouldRevalidateFunctionArgs) => didParamsChange(opts); } } diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 6471847f3e..a1cd20a27f 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -1,12 +1,11 @@ import * as React from "react"; import { decode } from "turbo-stream"; import type { Router as DataRouter } from "../../router/router"; -import { isResponse, runMiddlewarePipeline } from "../../router/router"; +import { isResponse } from "../../router/router"; import type { DataStrategyFunction, DataStrategyFunctionArgs, DataStrategyResult, - DataStrategyMatch, } from "../../router/utils"; import { ErrorResponseImpl, @@ -16,11 +15,9 @@ import { stripBasename, } from "../../router/utils"; import { createRequestInit } from "./data"; -import type { AssetsManifest, EntryContext } from "./entry"; +import type { EntryContext } from "./entry"; import { escapeHtml } from "./markup"; -import type { RouteModule, RouteModules } from "./routeModules"; import invariant from "./invariant"; -import type { EntryRoute } from "./routes"; export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); @@ -32,15 +29,22 @@ export type SingleFetchRedirectResult = { replace: boolean; }; +// Shared/serializable type used by both turbo-stream and RSC implementations +type DecodedSingleFetchResults = + | { routes: { [key: string]: SingleFetchResult } } + | { redirect: SingleFetchRedirectResult }; + +// This and SingleFetchResults are only used over the wire, and are converted to +// DecodedSingleFetchResults in `fetchAndDecode`. This way turbo-stream/RSC +// can use the same `unwrapSingleFetchResult` implementation. export type SingleFetchResult = | { data: unknown } | { error: unknown } | SingleFetchRedirectResult; -export type SingleFetchResults = { - [key: string]: SingleFetchResult; - [SingleFetchRedirectSymbol]?: SingleFetchRedirectResult; -}; +export type SingleFetchResults = + | { [key: string]: SingleFetchResult } + | { [SingleFetchRedirectSymbol]: SingleFetchRedirectResult }; interface StreamTransferProps { context: EntryContext; @@ -50,6 +54,16 @@ interface StreamTransferProps { nonce?: string; } +// Some status codes are not permitted to have bodies, so we want to just +// treat those as "no data" instead of throwing an exception: +// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx +// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content +// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content +// +// Note: 304 is not included here because the browser should fill those responses +// with the cached body content. +export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]); + // StreamTransfer recursively renders down chunks of the `serverHandoffStream` // into the client-side `streamController` export function StreamTransfer({ @@ -133,32 +147,55 @@ export function StreamTransfer({ } } -function handleMiddlewareError(error: unknown, routeId: string) { - return { [routeId]: { type: "error", result: error } }; -} +type GetRouteInfoFunction = (routeId: string) => { + hasLoader: boolean; + hasClientLoader: boolean; // TODO: Can this be read from match.route? + hasShouldRevalidate: boolean | undefined; // TODO: Can this be read from match.route? +}; + +type FetchAndDecodeFunction = ( + args: DataStrategyFunctionArgs, + basename: string | undefined, + targetRoutes?: string[] +) => Promise<{ status: number; data: DecodedSingleFetchResults }>; export function getSingleFetchDataStrategy( - manifest: AssetsManifest, - routeModules: RouteModules, + getRouter: () => DataRouter, + getRouteInfo: GetRouteInfoFunction, ssr: boolean, - basename: string | undefined, - getRouter: () => DataRouter + basename: string | undefined +): DataStrategyFunction { + let dataStrategy = getSingleFetchDataStrategyImpl( + getRouter, + getRouteInfo, + fetchAndDecodeViaTurboStream, + ssr, + basename + ); + return async (args) => args.unstable_runClientMiddleware(dataStrategy); +} + +export function getSingleFetchDataStrategyImpl( + getRouter: () => DataRouter, + getRouteInfo: GetRouteInfoFunction, + fetchAndDecode: FetchAndDecodeFunction, + ssr: boolean, + basename: string | undefined ): DataStrategyFunction { return async (args) => { let { request, matches, fetcherKey } = args; + let router = getRouter(); // Actions are simple and behave the same for navigations and fetchers if (request.method !== "GET") { - return runMiddlewarePipeline( - args, - false, - () => singleFetchActionStrategy(request, matches, basename), - handleMiddlewareError - ) as Promise>; + return singleFetchActionStrategy(args, fetchAndDecode, basename); } - // TODO: Enable middleware for this flow - if (!ssr) { + let foundRevalidatingServerLoader = matches.some((m) => { + let { hasLoader, hasClientLoader } = getRouteInfo(m.route.id); + return m.unstable_shouldCallHandler() && hasLoader && !hasClientLoader; + }); + if (!ssr && !foundRevalidatingServerLoader) { // If this is SPA mode, there won't be any loaders below root and we'll // disable single fetch. We have to keep the `dataStrategy` defined for // SPA mode because we may load a SPA fallback page but then navigate into @@ -191,71 +228,43 @@ export function getSingleFetchDataStrategy( // errored otherwise // - So it's safe to make the call knowing there will be a `.data` file on // the other end - let foundRevalidatingServerLoader = matches.some( - (m) => - m.shouldLoad && - manifest.routes[m.route.id]?.hasLoader && - !manifest.routes[m.route.id]?.hasClientLoader - ); - if (!foundRevalidatingServerLoader) { - return runMiddlewarePipeline( - args, - false, - () => nonSsrStrategy(manifest, request, matches, basename), - handleMiddlewareError - ) as Promise>; - } + return nonSsrStrategy(args, getRouteInfo, fetchAndDecode, basename); } // Fetcher loads are singular calls to one loader if (fetcherKey) { - return runMiddlewarePipeline( - args, - false, - () => singleFetchLoaderFetcherStrategy(request, matches, basename), - handleMiddlewareError - ) as Promise>; + return singleFetchLoaderFetcherStrategy(args, fetchAndDecode, basename); } // Navigational loads are more complex... - return runMiddlewarePipeline( + return singleFetchLoaderNavigationStrategy( args, - false, - () => - singleFetchLoaderNavigationStrategy( - manifest, - routeModules, - ssr, - getRouter(), - request, - matches, - basename - ), - handleMiddlewareError - ) as Promise>; + router, + getRouteInfo, + fetchAndDecode, + ssr, + basename + ); }; } // Actions are simple since they're singular calls to the server for both // navigations and fetchers) async function singleFetchActionStrategy( - request: Request, - matches: DataStrategyFunctionArgs["matches"], + args: DataStrategyFunctionArgs, + fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { - let actionMatch = matches.find((m) => m.shouldLoad); + let actionMatch = args.matches.find((m) => m.unstable_shouldCallHandler()); invariant(actionMatch, "No action match found"); let actionStatus: number | undefined = undefined; let result = await actionMatch.resolve(async (handler) => { let result = await handler(async () => { - let url = singleFetchUrl(request.url, basename); - let init = await createRequestInit(request); - let { data, status } = await fetchAndDecode(url, init); + let { data, status } = await fetchAndDecode(args, basename, [ + actionMatch!.route.id, + ]); actionStatus = status; - return unwrapSingleFetchResult( - data as SingleFetchResult, - actionMatch!.route.id - ); + return unwrapSingleFetchResult(data, actionMatch!.route.id); }); return result; }); @@ -276,24 +285,29 @@ async function singleFetchActionStrategy( // We want to opt-out of Single Fetch when we aren't in SSR mode async function nonSsrStrategy( - manifest: AssetsManifest, - request: Request, - matches: DataStrategyFunctionArgs["matches"], + args: DataStrategyFunctionArgs, + getRouteInfo: GetRouteInfoFunction, + fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { - let matchesToLoad = matches.filter((m) => m.shouldLoad); - let url = stripIndexParam(singleFetchUrl(request.url, basename)); - let init = await createRequestInit(request); + let matchesToLoad = args.matches.filter((m) => + m.unstable_shouldCallHandler() + ); let results: Record = {}; await Promise.all( matchesToLoad.map((m) => m.resolve(async (handler) => { try { + let { hasClientLoader } = getRouteInfo(m.route.id); // Need to pass through a `singleFetch` override handler so // clientLoader's can still call server loaders through `.data` // requests - let result = manifest.routes[m.route.id]?.hasClientLoader - ? await fetchSingleLoader(handler, url, init, m.route.id) + let routeId = m.route.id; + let result = hasClientLoader + ? await handler(async () => { + let { data } = await fetchAndDecode(args, basename, [routeId]); + return unwrapSingleFetchResult(data, routeId); + }) : await handler(); results[m.route.id] = { type: "data", result }; } catch (e) { @@ -308,121 +322,91 @@ async function nonSsrStrategy( // Loaders are trickier since we only want to hit the server once, so we // create a singular promise for all server-loader routes to latch onto. async function singleFetchLoaderNavigationStrategy( - manifest: AssetsManifest, - routeModules: RouteModules, - ssr: boolean, + args: DataStrategyFunctionArgs, router: DataRouter, - request: Request, - matches: DataStrategyFunctionArgs["matches"], + getRouteInfo: GetRouteInfoFunction, + fetchAndDecode: FetchAndDecodeFunction, + ssr: boolean, basename: string | undefined ) { - // Track which routes need a server load - in case we need to tack on a - // `_routes` param + // Track which routes need a server load for use in a `_routes` param let routesParams = new Set(); - // We only add `_routes` when one or more routes opts out of a load via - // `shouldRevalidate` or `clientLoader` + // Only add `_routes` when at least 1 route opts out via `shouldRevalidate`/`clientLoader` let foundOptOutRoute = false; - // Deferreds for each route so we can be sure they've all loaded via - // `match.resolve()`, and a singular promise that can tell us all routes - // have been resolved - let routeDfds = matches.map(() => createDeferred()); - let routesLoadedPromise = Promise.all(routeDfds.map((d) => d.promise)); + // Deferreds per-route so we can be sure they've all loaded via `match.resolve()` + let routeDfds = args.matches.map(() => createDeferred()); - // Deferred that we'll use for the call to the server that each match can - // await and parse out it's specific result - let singleFetchDfd = createDeferred(); - - // Base URL and RequestInit for calls to the server - let url = stripIndexParam(singleFetchUrl(request.url, basename)); - let init = await createRequestInit(request); + // Deferred we'll use for the singleular call to the server + let singleFetchDfd = createDeferred(); // We'll build up this results object as we loop through matches let results: Record = {}; let resolvePromise = Promise.all( - matches.map(async (m, i) => + args.matches.map(async (m, i) => m.resolve(async (handler) => { routeDfds[i].resolve(); - - let manifestRoute = manifest.routes[m.route.id]; - - // Note: If this logic changes for routes that should not participate - // in Single Fetch, make sure you update getLowestLoadingIndex above - // as well - if (!m.shouldLoad) { - // If we're not yet initialized and this is the initial load, respect - // `shouldLoad` because we're only dealing with `clientLoader.hydrate` - // routes which will fall into the `clientLoader` section below. - if (!router.state.initialized) { - return; - } - - // Otherwise, we opt out if we currently have data and a - // `shouldRevalidate` function. This implies that the user opted out - // via `shouldRevalidate` - if ( - m.route.id in router.state.loaderData && - manifestRoute && - m.route.shouldRevalidate - ) { - if (manifestRoute.hasLoader) { - // If we have a server loader, make sure we don't include it in the - // single fetch .data request - foundOptOutRoute = true; - } - return; - } + let routeId = m.route.id; + let { hasLoader, hasClientLoader, hasShouldRevalidate } = + getRouteInfo(routeId); + + let defaultShouldRevalidate = + !m.unstable_shouldRevalidateArgs || + m.unstable_shouldRevalidateArgs.actionStatus == null || + m.unstable_shouldRevalidateArgs.actionStatus < 400; + let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate); + + if (!shouldCall) { + // If this route opted out, don't include in the .data request + foundOptOutRoute ||= + m.unstable_shouldRevalidateArgs != null && // This is a revalidation, + hasLoader && // for a route with a server loader, + hasShouldRevalidate === true; // and a shouldRevalidate function + return; } // When a route has a client loader, it opts out of the singular call and // calls it's server loader via `serverLoader()` using a `?_routes` param - if (manifestRoute && manifestRoute.hasClientLoader) { - if (manifestRoute.hasLoader) { + if (hasClientLoader) { + if (hasLoader) { foundOptOutRoute = true; } try { - let result = await fetchSingleLoader( - handler, - url, - init, - m.route.id - ); - results[m.route.id] = { type: "data", result }; + let result = await handler(async () => { + let { data } = await fetchAndDecode(args, basename, [routeId]); + return unwrapSingleFetchResult(data, routeId); + }); + + results[routeId] = { type: "data", result }; } catch (e) { - results[m.route.id] = { type: "error", result: e }; + results[routeId] = { type: "error", result: e }; } return; } // Load this route on the server if it has a loader - if (manifestRoute && manifestRoute.hasLoader) { - routesParams.add(m.route.id); + if (hasLoader) { + routesParams.add(routeId); } // Lump this match in with the others on a singular promise try { let result = await handler(async () => { let data = await singleFetchDfd.promise; - return unwrapSingleFetchResults(data, m.route.id); + return unwrapSingleFetchResult(data, routeId); }); - results[m.route.id] = { - type: "data", - result, - }; + results[routeId] = { type: "data", result }; } catch (e) { - results[m.route.id] = { - type: "error", - result: e, - }; + results[routeId] = { type: "error", result: e }; } }) ) ); // Wait for all routes to resolve above before we make the HTTP call - await routesLoadedPromise; + await Promise.all(routeDfds.map((d) => d.promise)); // We can skip the server call: // - On initial hydration - only clientLoaders can pass through via `clientLoader.hydrate` @@ -437,24 +421,17 @@ async function singleFetchLoaderNavigationStrategy( ) { singleFetchDfd.resolve({}); } else { + // When routes have opted out, add a `_routes` param to filter server loaders + // Skipped in `ssr:false` because we expect to be loading static `.data` files + let targetRoutes = + ssr && foundOptOutRoute && routesParams.size > 0 + ? [...routesParams.keys()] + : undefined; try { - // When one or more routes have opted out, we add a _routes param to - // limit the loaders to those that have a server loader and did not - // opt out - if (ssr && foundOptOutRoute && routesParams.size > 0) { - url.searchParams.set( - "_routes", - matches - .filter((m) => routesParams.has(m.route.id)) - .map((m) => m.route.id) - .join(",") - ); - } - - let data = await fetchAndDecode(url, init); - singleFetchDfd.resolve(data.data as SingleFetchResults); + let data = await fetchAndDecode(args, basename, targetRoutes); + singleFetchDfd.resolve(data.data); } catch (e) { - singleFetchDfd.reject(e as Error); + singleFetchDfd.reject(e); } } @@ -465,36 +442,22 @@ async function singleFetchLoaderNavigationStrategy( // Fetcher loader calls are much simpler than navigational loader calls async function singleFetchLoaderFetcherStrategy( - request: Request, - matches: DataStrategyFunctionArgs["matches"], + args: DataStrategyFunctionArgs, + fetchAndDecode: FetchAndDecodeFunction, basename: string | undefined ) { - let fetcherMatch = matches.find((m) => m.shouldLoad); + let fetcherMatch = args.matches.find((m) => m.unstable_shouldCallHandler()); invariant(fetcherMatch, "No fetcher match found"); - let result = await fetcherMatch.resolve(async (handler) => { - let url = stripIndexParam(singleFetchUrl(request.url, basename)); - let init = await createRequestInit(request); - return fetchSingleLoader(handler, url, init, fetcherMatch!.route.id); - }); + let routeId = fetcherMatch.route.id; + let result = await fetcherMatch.resolve(async (handler) => + handler(async () => { + let { data } = await fetchAndDecode(args, basename, [routeId]); + return unwrapSingleFetchResult(data, routeId); + }) + ); return { [fetcherMatch.route.id]: result }; } -function fetchSingleLoader( - handler: Parameters< - NonNullable[0]> - >[0], - url: URL, - init: RequestInit, - routeId: string -) { - return handler(async () => { - let singleLoaderUrl = new URL(url); - singleLoaderUrl.searchParams.set("_routes", routeId); - let { data } = await fetchAndDecode(singleLoaderUrl, init); - return unwrapSingleFetchResults(data as SingleFetchResults, routeId); - }); -} - function stripIndexParam(url: URL) { let indexValues = url.searchParams.getAll("index"); url.searchParams.delete("index"); @@ -538,11 +501,21 @@ export function singleFetchUrl( return url; } -async function fetchAndDecode( - url: URL, - init: RequestInit -): Promise<{ status: number; data: unknown }> { - let res = await fetch(url, init); +async function fetchAndDecodeViaTurboStream( + args: DataStrategyFunctionArgs, + basename: string | undefined, + targetRoutes?: string[] +): Promise<{ status: number; data: DecodedSingleFetchResults }> { + let { request } = args; + let url = singleFetchUrl(request.url, basename); + if (request.method === "GET") { + url = stripIndexParam(url); + if (targetRoutes) { + url.searchParams.set("_routes", targetRoutes.join(",")); + } + } + + let res = await fetch(url, await createRequestInit(request)); // If this 404'd without hitting the running server (most likely in a // pre-rendered app using a CDN), then bubble a standard 404 ErrorResponse @@ -550,27 +523,42 @@ async function fetchAndDecode( throw new ErrorResponseImpl(404, "Not Found", true); } - // some status codes are not permitted to have bodies, so we want to just - // treat those as "no data" instead of throwing an exception. - // 304 is not included here because the browser should fill those responses - // with the cached body content. - const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]); if (NO_BODY_STATUS_CODES.has(res.status)) { - if (!init.method || init.method === "GET") { - // SingleFetchResults can just have no routeId keys which will result - // in no data for all routes - return { status: res.status, data: {} }; - } else { - // SingleFetchResult is for a singular route and can specify no data - return { status: res.status, data: { data: undefined } }; + let routes: { [key: string]: SingleFetchResult } = {}; + // We get back just a single result for action requests - normalize that + // to a DecodedSingleFetchResults shape here + if (targetRoutes && request.method !== "GET") { + routes[targetRoutes[0]] = { data: undefined }; } + return { + status: res.status, + data: { routes }, + }; } invariant(res.body, "No response body to decode"); try { let decoded = await decodeViaTurboStream(res.body, window); - return { status: res.status, data: decoded.value }; + let data: DecodedSingleFetchResults; + if (request.method === "GET") { + let typed = decoded.value as SingleFetchResults; + if (SingleFetchRedirectSymbol in typed) { + data = { redirect: typed[SingleFetchRedirectSymbol] }; + } else { + data = { routes: typed }; + } + } else { + let typed = decoded.value as SingleFetchResult; + let routeId = targetRoutes?.[0]; + invariant(routeId, "No routeId found for single fetch call decoding"); + if ("redirect" in typed) { + data = { redirect: typed }; + } else { + data = { routes: { [routeId]: typed } }; + } + } + return { status: res.status, data }; } catch (e) { // Can't clone after consuming the body via turbo-stream so we can't // include the body here. In an ideal world we'd look for a turbo-stream @@ -637,37 +625,34 @@ export function decodeViaTurboStream( }); } -function unwrapSingleFetchResults( - results: SingleFetchResults, +function unwrapSingleFetchResult( + result: DecodedSingleFetchResults, routeId: string ) { - let redirect = results[SingleFetchRedirectSymbol]; - if (redirect) { - return unwrapSingleFetchResult(redirect, routeId); + if ("redirect" in result) { + let { + redirect: location, + revalidate, + reload, + replace, + status, + } = result.redirect; + throw redirect(location, { + status, + headers: { + // Three R's of redirecting (lol Veep) + ...(revalidate ? { "X-Remix-Revalidate": "yes" } : null), + ...(reload ? { "X-Remix-Reload-Document": "yes" } : null), + ...(replace ? { "X-Remix-Replace": "yes" } : null), + }, + }); } - return results[routeId] !== undefined - ? unwrapSingleFetchResult(results[routeId], routeId) - : null; -} - -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"; - } - if (result.replace) { - headers["X-Remix-Replace"] = "yes"; - } - throw redirect(result.redirect, { status: result.status, headers }); - } else if ("data" in result) { - return result.data; + let routeResult = result.routes[routeId]; + if ("error" in routeResult) { + throw routeResult.error; + } else if ("data" in routeResult) { + return routeResult.data; } else { throw new Error(`No response found for routeId "${routeId}"`); } @@ -675,7 +660,7 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { function createDeferred() { let resolve: (val?: any) => Promise; - let reject: (error?: Error) => Promise; + let reject: (error?: unknown) => Promise; let promise = new Promise((res, rej) => { resolve = async (val: T) => { res(val); @@ -683,7 +668,7 @@ function createDeferred() { await promise; } catch (e) {} }; - reject = async (error?: Error) => { + reject = async (error?: unknown) => { rej(error); try { await promise; diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index d785af7917..b5585cb978 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,3 +1,4 @@ +import type { DataRouteMatch } from "../context"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType, @@ -379,6 +380,7 @@ export interface RouterInit { unstable_getContext?: () => MaybePromise; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; + hydrationRouteProperties?: string[]; hydrationData?: HydrationState; window?: Window; dataStrategy?: DataStrategyFunction; @@ -728,7 +730,8 @@ interface FetchLoadMatch { interface RevalidatingFetcher extends FetchLoadMatch { key: string; match: AgnosticDataRouteMatch | null; - matches: AgnosticDataRouteMatch[] | null; + matches: DataStrategyMatch[] | null; + request: Request | null; controller: AbortController | null; } @@ -817,6 +820,7 @@ export function createRouter(init: RouterInit): Router { "You must provide a non-empty routes array to createRouter" ); + let hydrationRouteProperties = init.hydrationRouteProperties || []; let mapRouteProperties = init.mapRouteProperties || defaultMapRouteProperties; // Routes keyed by ID @@ -1628,6 +1632,7 @@ export function createRouter(init: RouterInit): Router { matches, scopedContext, fogOfWar.active, + opts && opts.initialHydration === true, { replace: opts.replace, flushSync } ); @@ -1719,6 +1724,7 @@ export function createRouter(init: RouterInit): Router { matches: AgnosticDataRouteMatch[], scopedContext: unstable_RouterContextProvider, isFogOfWar: boolean, + initialHydration: boolean, opts: { replace?: boolean; flushSync?: boolean } = {} ): Promise { interruptActiveLoads(); @@ -1781,11 +1787,18 @@ export function createRouter(init: RouterInit): Router { }), }; } else { - let results = await callDataStrategy( - "action", + let dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, request, - [actionMatch], matches, + actionMatch, + initialHydration ? [] : hydrationRouteProperties, + scopedContext + ); + let results = await callDataStrategy( + request, + dsMatches, scopedContext, null ); @@ -1945,12 +1958,17 @@ export function createRouter(init: RouterInit): Router { } let routesToUse = inFlightDataRoutes || dataRoutes; - let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + let { dsMatches, revalidatingFetchers } = getMatchesToLoad( + request, + scopedContext, + mapRouteProperties, + manifest, init.history, state, matches, activeSubmission, location, + initialHydration ? [] : hydrationRouteProperties, initialHydration === true, isRevalidationRequired, cancelledFetcherLoads, @@ -1964,8 +1982,13 @@ export function createRouter(init: RouterInit): Router { pendingNavigationLoadId = ++incrementingLoadId; - // Short circuit if we have no loaders to run - if (matchesToLoad.length === 0 && revalidatingFetchers.length === 0) { + // Short circuit if we have no loaders to run, unless there's a custom dataStrategy + // since they may have different revalidation rules (i.e., single fetch) + if ( + !init.dataStrategy && + !dsMatches.some((m) => m.shouldLoad) && + revalidatingFetchers.length === 0 + ) { let updatedFetchers = markFetchRedirectsDone(); completeNavigation( location, @@ -2023,8 +2046,7 @@ export function createRouter(init: RouterInit): Router { let { loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( - matches, - matchesToLoad, + dsMatches, revalidatingFetchers, request, scopedContext @@ -2299,11 +2321,18 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let actionResults = await callDataStrategy( - "action", + let fetchMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, fetchRequest, - [match], requestMatches, + match, + hydrationRouteProperties, + scopedContext + ); + let actionResults = await callDataStrategy( + fetchRequest, + fetchMatches, scopedContext, key ); @@ -2375,12 +2404,17 @@ export function createRouter(init: RouterInit): Router { let loadFetcher = getLoadingFetcher(submission, actionResult.data); state.fetchers.set(key, loadFetcher); - let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + let { dsMatches, revalidatingFetchers } = getMatchesToLoad( + revalidationRequest, + scopedContext, + mapRouteProperties, + manifest, init.history, state, matches, submission, nextLocation, + hydrationRouteProperties, false, isRevalidationRequired, cancelledFetcherLoads, @@ -2423,8 +2457,7 @@ export function createRouter(init: RouterInit): Router { let { loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( - matches, - matchesToLoad, + dsMatches, revalidatingFetchers, revalidationRequest, scopedContext @@ -2581,11 +2614,18 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; - let results = await callDataStrategy( - "loader", + let dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, fetchRequest, - [match], matches, + match, + hydrationRouteProperties, + scopedContext + ); + let results = await callDataStrategy( + fetchRequest, + dsMatches, scopedContext, key ); @@ -2774,10 +2814,8 @@ export function createRouter(init: RouterInit): Router { // Utility wrapper for calling dataStrategy client-side without having to // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( - type: "loader" | "action", request: Request, - matchesToLoad: AgnosticDataRouteMatch[], - matches: AgnosticDataRouteMatch[], + matches: DataStrategyMatch[], scopedContext: unstable_RouterContextProvider, fetcherKey: string | null ): Promise> { @@ -2786,24 +2824,23 @@ export function createRouter(init: RouterInit): Router { try { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, - type, request, - matchesToLoad, matches, fetcherKey, - manifest, - mapRouteProperties, - scopedContext + scopedContext, + false ); } catch (e) { // If the outer dataStrategy method throws, just return the error for all // matches - and it'll naturally bubble to the root - matchesToLoad.forEach((m) => { - dataResults[m.route.id] = { - type: ResultType.error, - error: e, - }; - }); + matches + .filter((m) => m.shouldLoad) + .forEach((m) => { + dataResults[m.route.id] = { + type: ResultType.error, + error: e, + }; + }); return dataResults; } @@ -2831,17 +2868,14 @@ export function createRouter(init: RouterInit): Router { } async function callLoadersAndMaybeResolveData( - matches: AgnosticDataRouteMatch[], - matchesToLoad: AgnosticDataRouteMatch[], + matches: DataStrategyMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, scopedContext: unstable_RouterContextProvider ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( - "loader", request, - matchesToLoad, matches, scopedContext, null @@ -2849,11 +2883,9 @@ export function createRouter(init: RouterInit): Router { let fetcherResultsPromise = Promise.all( fetchersToLoad.map(async (f) => { - if (f.matches && f.match && f.controller) { + if (f.matches && f.match && f.request && f.controller) { let results = await callDataStrategy( - "loader", - createClientSideRequest(init.history, f.path, f.controller.signal), - [f.match], + f.request, f.matches, scopedContext, f.key @@ -3892,11 +3924,19 @@ export function createStaticHandler( error, }; } else { - let results = await callDataStrategy( - "action", + let dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, request, - [actionMatch], matches, + actionMatch, + [], + requestContext + ); + + let results = await callDataStrategy( + request, + dsMatches, isRouteRequest, requestContext, dataStrategy @@ -4077,26 +4117,56 @@ export function createStaticHandler( }); } - let requestMatches = routeMatch - ? [routeMatch] - : pendingActionResult && isErrorResult(pendingActionResult[1]) - ? getLoaderMatchesUntilBoundary(matches, pendingActionResult[0]) - : matches; - let matchesToLoad = requestMatches.filter( - (m) => - (m.route.loader || m.route.lazy) && - (!filterMatchesToLoad || filterMatchesToLoad(m)) - ); + let dsMatches: DataStrategyMatch[]; + if (routeMatch) { + dsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + request, + matches, + routeMatch, + [], + requestContext + ); + } else { + let maxIdx = + pendingActionResult && isErrorResult(pendingActionResult[1]) + ? // Up to but not including the boundary + matches.findIndex((m) => m.route.id === pendingActionResult[0]) - 1 + : undefined; + + dsMatches = matches.map((match, index) => { + if (maxIdx != null && index > maxIdx) { + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + [], + requestContext, + false + ); + } - // Short circuit if we have no loaders to run (query()) - if (matchesToLoad.length === 0) { + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + [], + requestContext, + (match.route.loader || match.route.lazy) != null && + (!filterMatchesToLoad || filterMatchesToLoad(match)) + ); + }); + } + + // Short circuit if we have no loaders to run, unless there's a custom dataStrategy + // since they may have different revalidation rules (i.e., single fetch) + if (!dataStrategy && !dsMatches.some((m) => m.shouldLoad)) { return { matches, - // Add a null for all matched routes for proper revalidation on the client - loaderData: matches.reduce( - (acc, m) => Object.assign(acc, { [m.route.id]: null }), - {} - ), + loaderData: {}, errors: pendingActionResult && isErrorResult(pendingActionResult[1]) ? { @@ -4109,10 +4179,8 @@ export function createStaticHandler( } let results = await callDataStrategy( - "loader", request, - matchesToLoad, - matches, + dsMatches, isRouteRequest, requestContext, dataStrategy @@ -4131,16 +4199,6 @@ export function createStaticHandler( skipLoaderErrorBubbling ); - // Add a null for any non-loader matches for proper revalidation on the client - let executedLoaders = new Set( - matchesToLoad.map((match) => match.route.id) - ); - matches.forEach((match) => { - if (!executedLoaders.has(match.route.id)) { - handlerContext.loaderData[match.route.id] = null; - } - }); - return { ...handlerContext, matches, @@ -4150,24 +4208,19 @@ export function createStaticHandler( // Utility wrapper for calling dataStrategy server-side without having to // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( - type: "loader" | "action", request: Request, - matchesToLoad: AgnosticDataRouteMatch[], - matches: AgnosticDataRouteMatch[], + matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, dataStrategy: DataStrategyFunction | null ): Promise> { let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, - type, request, - matchesToLoad, matches, null, - manifest, - mapRouteProperties, - requestContext + requestContext, + true ); let dataResults: Record = {}; @@ -4480,26 +4533,17 @@ function normalizeNavigateOptions( return { path: createPath(parsedPath), submission }; } -// Filter out all routes at/below any caught error as they aren't going to -// render so we don't need to load them -function getLoaderMatchesUntilBoundary( - matches: AgnosticDataRouteMatch[], - boundaryId: string, - includeBoundary = false -) { - let index = matches.findIndex((m) => m.route.id === boundaryId); - if (index >= 0) { - return matches.slice(0, includeBoundary ? index + 1 : index); - } - return matches; -} - function getMatchesToLoad( + request: Request, + scopedContext: unknown, + mapRouteProperties: MapRoutePropertiesFunction, + manifest: RouteManifest, history: History, state: RouterState, matches: AgnosticDataRouteMatch[], submission: Submission | undefined, location: Location, + lazyRoutePropertiesToSkip: string[], initialHydration: boolean, isRevalidationRequired: boolean, cancelledFetcherLoads: Set, @@ -4509,7 +4553,10 @@ function getMatchesToLoad( routesToUse: AgnosticDataRouteObject[], basename: string | undefined, pendingActionResult?: PendingActionResult -): [AgnosticDataRouteMatch[], RevalidatingFetcher[]] { +): { + dsMatches: DataStrategyMatch[]; + revalidatingFetchers: RevalidatingFetcher[]; +} { let actionResult = pendingActionResult ? isErrorResult(pendingActionResult[1]) ? pendingActionResult[1].error @@ -4519,25 +4566,20 @@ function getMatchesToLoad( let nextUrl = history.createURL(location); // Pick navigation matches that are net-new or qualify for revalidation - let boundaryMatches = matches; + let maxIdx: number | undefined; if (initialHydration && state.errors) { // On initial hydration, only consider matches up to _and including_ the boundary. // This is inclusive to handle cases where a server loader ran successfully, // a child server loader bubbled up to this route, but this route has // `clientLoader.hydrate` so we want to still run the `clientLoader` so that // we have a complete version of `loaderData` - boundaryMatches = getLoaderMatchesUntilBoundary( - matches, - Object.keys(state.errors)[0], - true - ); + let boundaryId = Object.keys(state.errors)[0]; + maxIdx = matches.findIndex((m) => m.route.id === boundaryId); } else if (pendingActionResult && isErrorResult(pendingActionResult[1])) { // If an action threw an error, we call loaders up to, but not including the // boundary - boundaryMatches = getLoaderMatchesUntilBoundary( - matches, - pendingActionResult[0] - ); + let boundaryId = pendingActionResult[0]; + maxIdx = matches.findIndex((m) => m.route.id === boundaryId) - 1; } // Don't revalidate loaders by default after action 4xx/5xx responses @@ -4548,51 +4590,84 @@ function getMatchesToLoad( : undefined; let shouldSkipRevalidation = actionStatus && actionStatus >= 400; - let navigationMatches = boundaryMatches.filter((match, index) => { + let baseShouldRevalidateArgs = { + currentUrl, + currentParams: state.matches[0]?.params || {}, + nextUrl, + nextParams: matches[0].params, + ...submission, + actionResult, + actionStatus, + }; + + let dsMatches: DataStrategyMatch[] = matches.map((match, index) => { let { route } = match; - if (route.lazy) { - // We haven't loaded this route yet so we don't know if it's got a loader! - return true; - } - if (route.loader == null) { - return false; - } + // For these cases we don't let the user have control via shouldRevalidate + // and we either force the loader to run or not run + let forceShouldLoad: boolean | null = null; - if (initialHydration) { - return shouldLoadRouteOnHydration(route, state.loaderData, state.errors); + if (maxIdx != null && index > maxIdx) { + // Don't call loaders below the boundary + forceShouldLoad = false; + } else if (route.lazy) { + // We haven't loaded this route yet so we don't know if it's got a loader! + forceShouldLoad = true; + } else if (route.loader == null) { + // Nothing to load! + forceShouldLoad = false; + } else if (initialHydration) { + // Only run on hydration if this is a hydrating `clientLoader` + forceShouldLoad = shouldLoadRouteOnHydration( + route, + state.loaderData, + state.errors + ); + } else if (isNewLoader(state.loaderData, state.matches[index], match)) { + // Always call the loader on new route instances + forceShouldLoad = true; } - // Always call the loader on new route instances - if (isNewLoader(state.loaderData, state.matches[index], match)) { - return true; + if (forceShouldLoad !== null) { + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip, + scopedContext, + forceShouldLoad + ); } // This is the default implementation for when we revalidate. If the route // provides it's own implementation, then we give them full control but // provide this value so they can leverage it if needed after they check // their own specific use cases - let currentRouteMatch = state.matches[index]; - let nextRouteMatch = match; - - return shouldRevalidateLoader(match, { - currentUrl, - currentParams: currentRouteMatch.params, - nextUrl, - nextParams: nextRouteMatch.params, - ...submission, - actionResult, - actionStatus, - defaultShouldRevalidate: shouldSkipRevalidation - ? false - : // Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate - isRevalidationRequired || - currentUrl.pathname + currentUrl.search === - nextUrl.pathname + nextUrl.search || - // Search params affect all loaders - currentUrl.search !== nextUrl.search || - isNewRouteInstance(currentRouteMatch, nextRouteMatch), - }); + let defaultShouldRevalidate = shouldSkipRevalidation + ? false + : // Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate + isRevalidationRequired || + currentUrl.pathname + currentUrl.search === + nextUrl.pathname + nextUrl.search || + // Search params affect all loaders + currentUrl.search !== nextUrl.search || + isNewRouteInstance(state.matches[index], match); + let shouldRevalidateArgs = { + ...baseShouldRevalidateArgs, + defaultShouldRevalidate, + }; + let shouldLoad = shouldRevalidateLoader(match, shouldRevalidateArgs); + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip, + scopedContext, + shouldLoad, + shouldRevalidateArgs + ); }); // Pick fetcher.loads that need to be revalidated @@ -4624,64 +4699,100 @@ function getMatchesToLoad( path: f.path, matches: null, match: null, + request: null, controller: null, }); return; } + if (fetchRedirectIds.has(key)) { + // Never trigger a revalidation of an actively redirecting fetcher + return; + } + // Revalidating fetchers are decoupled from the route matches since they // load from a static href. They revalidate based on explicit revalidation // (submission, useRevalidator, or X-Remix-Revalidate) let fetcher = state.fetchers.get(key); let fetcherMatch = getTargetMatch(fetcherMatches, f.path); - let shouldRevalidate = false; - if (fetchRedirectIds.has(key)) { - // Never trigger a revalidation of an actively redirecting fetcher - shouldRevalidate = false; - } else if (cancelledFetcherLoads.has(key)) { + let fetchController = new AbortController(); + let fetchRequest = createClientSideRequest( + history, + f.path, + fetchController.signal + ); + + let fetcherDsMatches: DataStrategyMatch[] | null = null; + + if (cancelledFetcherLoads.has(key)) { // Always mark for revalidation if the fetcher was cancelled cancelledFetcherLoads.delete(key); - shouldRevalidate = true; + fetcherDsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + fetchRequest, + fetcherMatches, + fetcherMatch, + lazyRoutePropertiesToSkip, + scopedContext + ); } else if ( fetcher && fetcher.state !== "idle" && fetcher.data === undefined ) { - // If the fetcher hasn't ever completed loading yet, then this isn't a - // revalidation, it would just be a brand new load if an explicit - // revalidation is required - shouldRevalidate = isRevalidationRequired; + if (isRevalidationRequired) { + // If the fetcher hasn't ever completed loading yet, then this isn't a + // revalidation, it would just be a brand new load if an explicit + // revalidation is required + fetcherDsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + fetchRequest, + fetcherMatches, + fetcherMatch, + lazyRoutePropertiesToSkip, + scopedContext + ); + } } else { // Otherwise fall back on any user-defined shouldRevalidate, defaulting // to explicit revalidations only - shouldRevalidate = shouldRevalidateLoader(fetcherMatch, { - currentUrl, - currentParams: state.matches[state.matches.length - 1].params, - nextUrl, - nextParams: matches[matches.length - 1].params, - ...submission, - actionResult, - actionStatus, + let shouldRevalidateArgs: ShouldRevalidateFunctionArgs = { + ...baseShouldRevalidateArgs, defaultShouldRevalidate: shouldSkipRevalidation ? false : isRevalidationRequired, - }); + }; + if (shouldRevalidateLoader(fetcherMatch, shouldRevalidateArgs)) { + fetcherDsMatches = getTargetedDataStrategyMatches( + mapRouteProperties, + manifest, + fetchRequest, + fetcherMatches, + fetcherMatch, + lazyRoutePropertiesToSkip, + scopedContext, + shouldRevalidateArgs + ); + } } - if (shouldRevalidate) { + if (fetcherDsMatches) { revalidatingFetchers.push({ key, routeId: f.routeId, path: f.path, - matches: fetcherMatches, + matches: fetcherDsMatches, match: fetcherMatch, - controller: new AbortController(), + request: fetchRequest, + controller: fetchController, }); } }); - return [navigationMatches, revalidatingFetchers]; + return { dsMatches, revalidatingFetchers }; } function shouldLoadRouteOnHydration( @@ -4944,7 +5055,8 @@ function loadLazyRoute( route: AgnosticDataRouteObject, type: "loader" | "action", manifest: RouteManifest, - mapRouteProperties: MapRoutePropertiesFunction + mapRouteProperties: MapRoutePropertiesFunction, + lazyRoutePropertiesToSkip?: string[] ): { lazyRoutePromise: Promise | undefined; lazyHandlerPromise: Promise | undefined; @@ -5043,6 +5155,9 @@ function loadLazyRoute( lazyRouteFunctionCache.set(routeToUpdate, lazyRoutePromise); + // Prevent unhandled rejection errors - handled inside of `callLoadOrAction` + lazyRoutePromise.catch(() => {}); + return { lazyRoutePromise, lazyHandlerPromise: lazyRoutePromise, @@ -5054,6 +5169,10 @@ function loadLazyRoute( let lazyHandlerPromise: Promise | undefined = undefined; for (let key of lazyKeys) { + if (lazyRoutePropertiesToSkip && lazyRoutePropertiesToSkip.includes(key)) { + continue; + } + let promise = loadLazyRouteProperty({ key, route, @@ -5068,9 +5187,16 @@ function loadLazyRoute( } } - let lazyRoutePromise = Promise.all(lazyPropertyPromises) - // Ensure type is Promise, not Promise - .then(() => {}); + let lazyRoutePromise = + lazyPropertyPromises.length > 0 + ? Promise.all(lazyPropertyPromises) + // Ensure type is Promise, not Promise + .then(() => {}) + : undefined; + + // Prevent unhandled rejection errors - handled inside of `callLoadOrAction` + lazyRoutePromise?.catch(() => {}); + lazyHandlerPromise?.catch(() => {}); return { lazyRoutePromise, @@ -5281,88 +5407,214 @@ async function callRouteMiddleware( } } -async function callDataStrategyImpl( - dataStrategyImpl: DataStrategyFunction, - type: "loader" | "action", - request: Request, - matchesToLoad: AgnosticDataRouteMatch[], - matches: AgnosticDataRouteMatch[], - fetcherKey: string | null, - manifest: RouteManifest, +function getDataStrategyMatchLazyPromises( mapRouteProperties: MapRoutePropertiesFunction, - scopedContext: unknown -): Promise> { - // Ensure all lazy/lazyMiddleware async functions are kicked off in parallel - // before we await them where needed below - let loadMiddlewarePromise = loadLazyMiddlewareForMatches( - matches, + manifest: RouteManifest, + request: Request, + match: DataRouteMatch, + lazyRoutePropertiesToSkip: string[] +): DataStrategyMatch["_lazyPromises"] { + let lazyMiddlewarePromise = loadLazyRouteProperty({ + key: "unstable_middleware", + route: match.route, + manifest, + mapRouteProperties, + }); + + let lazyRoutePromises = loadLazyRoute( + match.route, + isMutationMethod(request.method) ? "action" : "loader", manifest, - mapRouteProperties + mapRouteProperties, + lazyRoutePropertiesToSkip ); - let lazyRoutePromises = matches.map((m) => - loadLazyRoute(m.route, type, manifest, mapRouteProperties) + + return { + middleware: lazyMiddlewarePromise, + route: lazyRoutePromises.lazyRoutePromise, + handler: lazyRoutePromises.lazyHandlerPromise, + }; +} + +function getDataStrategyMatch( + mapRouteProperties: MapRoutePropertiesFunction, + manifest: RouteManifest, + request: Request, + match: DataRouteMatch, + lazyRoutePropertiesToSkip: string[], + scopedContext: unknown, + shouldLoad: boolean, + unstable_shouldRevalidateArgs: DataStrategyMatch["unstable_shouldRevalidateArgs"] = null +): DataStrategyMatch { + // The hope here is to avoid a breaking change to the resolve behavior. + // Opt-ing into the `unstable_shouldCallHandler` API changes some nuanced behavior + // around when resolve calls through to the handler + let isUsingNewApi = false; + + let _lazyPromises = getDataStrategyMatchLazyPromises( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip ); - // Ensure all middleware is loaded before we start executing routes - if (loadMiddlewarePromise) { - await loadMiddlewarePromise; - } + return { + ...match, + _lazyPromises, + shouldLoad, + unstable_shouldRevalidateArgs, + unstable_shouldCallHandler(defaultShouldRevalidate) { + isUsingNewApi = true; + if (!unstable_shouldRevalidateArgs) { + return shouldLoad; + } - let dsMatches = matches.map((match, i) => { - let { lazyRoutePromise, lazyHandlerPromise } = lazyRoutePromises[i]; - let shouldLoad = matchesToLoad.some((m) => m.route.id === match.route.id); - // `resolve` encapsulates route.lazy(), executing the loader/action, - // and mapping return values/thrown errors to a `DataStrategyResult`. Users - // can pass a callback to take fine-grained control over the execution - // of the loader/action - let resolve: DataStrategyMatch["resolve"] = async (handlerOverride) => { + if (typeof defaultShouldRevalidate === "boolean") { + return shouldRevalidateLoader(match, { + ...unstable_shouldRevalidateArgs, + defaultShouldRevalidate, + }); + } + return shouldRevalidateLoader(match, unstable_shouldRevalidateArgs); + }, + resolve(handlerOverride) { if ( - handlerOverride && - request.method === "GET" && - (match.route.lazy || match.route.loader) + isUsingNewApi || + shouldLoad || + (handlerOverride && + request.method === "GET" && + (match.route.lazy || match.route.loader)) ) { - shouldLoad = true; + return callLoaderOrAction({ + request, + match, + lazyHandlerPromise: _lazyPromises?.handler, + lazyRoutePromise: _lazyPromises?.route, + handlerOverride, + scopedContext, + }); } - return shouldLoad - ? callLoaderOrAction({ - type, - request, - match, - lazyHandlerPromise, - lazyRoutePromise, - handlerOverride, - scopedContext, - }) - : Promise.resolve({ type: ResultType.data, result: undefined }); - }; + return Promise.resolve({ type: ResultType.data, result: undefined }); + }, + }; +} - return { - ...match, - shouldLoad, - resolve, - }; +function getTargetedDataStrategyMatches( + mapRouteProperties: MapRoutePropertiesFunction, + manifest: RouteManifest, + request: Request, + matches: AgnosticDataRouteMatch[], + targetMatch: AgnosticDataRouteMatch, + lazyRoutePropertiesToSkip: string[], + scopedContext: unknown, + shouldRevalidateArgs: DataStrategyMatch["unstable_shouldRevalidateArgs"] = null +): DataStrategyMatch[] { + return matches.map((match) => { + if (match.route.id !== targetMatch.route.id) { + // We don't use getDataStrategyMatch here because these are for actions/fetchers + // where we should _never_ call the handler for any matches other than the target + return { + ...match, + shouldLoad: false, + unstable_shouldRevalidateArgs: shouldRevalidateArgs, + unstable_shouldCallHandler: () => false, + _lazyPromises: getDataStrategyMatchLazyPromises( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip + ), + resolve: () => Promise.resolve({ type: "data", result: undefined }), + }; + } + + return getDataStrategyMatch( + mapRouteProperties, + manifest, + request, + match, + lazyRoutePropertiesToSkip, + scopedContext, + true, + shouldRevalidateArgs + ); }); +} + +async function callDataStrategyImpl( + dataStrategyImpl: DataStrategyFunction, + request: Request, + matches: DataStrategyMatch[], + fetcherKey: string | null, + scopedContext: unknown, + isStaticHandler: boolean +): Promise> { + // Ensure all middleware is loaded before we start executing routes + if (matches.some((m) => m._lazyPromises?.middleware)) { + await Promise.all(matches.map((m) => m._lazyPromises?.middleware)); + } // Send all matches here to allow for a middleware-type implementation. // handler will be a no-op for unneeded routes and we filter those results // back out below. - let results = await dataStrategyImpl({ - matches: dsMatches, + let dataStrategyArgs = { request, params: matches[0].params, - fetcherKey, context: scopedContext, + matches, + }; + let unstable_runClientMiddleware = isStaticHandler + ? () => { + throw new Error( + "You cannot call `unstable_runClientMiddleware()` from a static handler " + + "`dataStrategy`. Middleware is run outside of `dataStrategy` during " + + "SSR in order to bubble up the Response. You can enable middleware " + + "via the `respond` API in `query`/`queryRoute`" + ); + } + : (cb: DataStrategyFunction) => { + let typedDataStrategyArgs = dataStrategyArgs as ( + | LoaderFunctionArgs + | ActionFunctionArgs + ) & { + matches: DataStrategyMatch[]; + }; + return runMiddlewarePipeline( + typedDataStrategyArgs, + false, + () => + cb({ + ...typedDataStrategyArgs, + fetcherKey, + unstable_runClientMiddleware: () => { + throw new Error( + "Cannot call `unstable_runClientMiddleware()` from within an " + + "`unstable_runClientMiddleware` handler" + ); + }, + }), + (error: unknown, routeId: string) => ({ + [routeId]: { type: "error", result: error }, + }) + ) as Promise>; + }; + + let results = await dataStrategyImpl({ + ...dataStrategyArgs, + fetcherKey, + unstable_runClientMiddleware, }); // Wait for all routes to load here but swallow the error since we want // it to bubble up from the `await loadRoutePromise` in `callLoaderOrAction` - // called from `match.resolve()`. We also ensure that all promises are // awaited so that we don't inadvertently leave any hanging promises. - let allLazyRoutePromises: Promise[] = lazyRoutePromises.flatMap( - (promiseMap) => Object.values(promiseMap).filter(isNonNullable) - ); try { - await Promise.all(allLazyRoutePromises); + await Promise.all( + matches.flatMap((m) => [m._lazyPromises?.handler, m._lazyPromises?.route]) + ); } catch (e) { // No-op } @@ -5372,7 +5624,6 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ - type, request, match, lazyHandlerPromise, @@ -5380,7 +5631,6 @@ async function callLoaderOrAction({ handlerOverride, scopedContext, }: { - type: "loader" | "action"; request: Request; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; @@ -5390,7 +5640,8 @@ async function callLoaderOrAction({ }): Promise { let result: DataStrategyResult; let onReject: (() => void) | undefined; - + let isAction = isMutationMethod(request.method); + let type = isAction ? "action" : "loader"; let runHandler = ( handler: boolean | LoaderFunction | ActionFunction ): Promise => { @@ -5436,9 +5687,7 @@ async function callLoaderOrAction({ }; try { - let handler = match.route[type] as - | LoaderFunction - | ActionFunction; + let handler = isAction ? match.route.action : match.route.loader; // If we have a promise for a lazy route, await that first if (lazyHandlerPromise || lazyRoutePromise) { @@ -5464,9 +5713,7 @@ async function callLoaderOrAction({ // Load lazy loader/action before running it await lazyHandlerPromise; - handler = match.route[type] as - | LoaderFunction - | ActionFunction; + let handler = isAction ? match.route.action : match.route.loader; if (handler) { // Handler still runs even if we got interrupted to maintain consistency // with un-abortable behavior of handler execution on non-lazy or @@ -5833,33 +6080,37 @@ function processLoaderData( ); // Process results from our revalidating fetchers - revalidatingFetchers.forEach((rf) => { - let { key, match, controller } = rf; - let result = fetcherResults[key]; - invariant(result, "Did not find corresponding fetcher result"); - - // Process fetcher non-redirect errors - if (controller && controller.signal.aborted) { - // Nothing to do for aborted fetchers - return; - } else if (isErrorResult(result)) { - let boundaryMatch = findNearestBoundary(state.matches, match?.route.id); - if (!(errors && errors[boundaryMatch.route.id])) { - errors = { - ...errors, - [boundaryMatch.route.id]: result.error, - }; + revalidatingFetchers + // Keep those with no matches so we can bubble their 404's, otherwise only + // process fetchers that needed to load + .filter((f) => !f.matches || f.matches.some((m) => m.shouldLoad)) + .forEach((rf) => { + let { key, match, controller } = rf; + let result = fetcherResults[key]; + invariant(result, "Did not find corresponding fetcher result"); + + // Process fetcher non-redirect errors + if (controller && controller.signal.aborted) { + // Nothing to do for aborted fetchers + return; + } else if (isErrorResult(result)) { + let boundaryMatch = findNearestBoundary(state.matches, match?.route.id); + if (!(errors && errors[boundaryMatch.route.id])) { + errors = { + ...errors, + [boundaryMatch.route.id]: result.error, + }; + } + state.fetchers.delete(key); + } else if (isRedirectResult(result)) { + // Should never get here, redirects should get processed above, but we + // keep this to type narrow to a success result in the else + invariant(false, "Unhandled fetcher revalidation redirect"); + } else { + let doneFetcher = getDoneFetcher(result.data); + state.fetchers.set(key, doneFetcher); } - state.fetchers.delete(key); - } else if (isRedirectResult(result)) { - // Should never get here, redirects should get processed above, but we - // keep this to type narrow to a success result in the else - invariant(false, "Unhandled fetcher revalidation redirect"); - } else { - let doneFetcher = getDoneFetcher(result.data); - state.fetchers.set(key, doneFetcher); - } - }); + }); return { loaderData, errors }; } diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index c375d3c42c..cebea7406f 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1,3 +1,4 @@ +import type { MiddlewareEnabled } from "../types/future"; import type { Equal, Expect } from "../types/utils"; import type { Location, Path, To } from "./history"; import { invariant, parsePath, warning } from "./history"; @@ -172,6 +173,10 @@ export class unstable_RouterContextProvider { } } +type DefaultContext = MiddlewareEnabled extends true + ? unstable_RouterContextProvider + : any; + /** * @private * Arguments passed to route loader/action functions. Same for now but we keep @@ -225,13 +230,13 @@ export type unstable_MiddlewareFunction = ( /** * Arguments passed to loader functions */ -export interface LoaderFunctionArgs +export interface LoaderFunctionArgs extends DataFunctionArgs {} /** * Arguments passed to action functions */ -export interface ActionFunctionArgs +export interface ActionFunctionArgs extends DataFunctionArgs {} /** @@ -244,7 +249,7 @@ type DataFunctionReturnValue = MaybePromise; /** * Route loader function signature */ -export type LoaderFunction = { +export type LoaderFunction = { ( args: LoaderFunctionArgs, handlerCtx?: unknown @@ -254,7 +259,7 @@ export type LoaderFunction = { /** * Route action function signature */ -export interface ActionFunction { +export interface ActionFunction { ( args: ActionFunctionArgs, handlerCtx?: unknown @@ -331,7 +336,21 @@ export interface ShouldRevalidateFunction { export interface DataStrategyMatch extends AgnosticRouteMatch { + /** + * @private + */ + _lazyPromises?: { + middleware: Promise | undefined; + handler: Promise | undefined; + route: Promise | undefined; + }; shouldLoad: boolean; + // This can be null for actions calls and for initial hydration calls + unstable_shouldRevalidateArgs: ShouldRevalidateFunctionArgs | null; + // This function will use a scoped version of `shouldRevalidateArgs` because + // they are read-only but let the user provide an optional override value for + // `defaultShouldRevalidate` if they choose + unstable_shouldCallHandler(defaultShouldRevalidate?: boolean): boolean; resolve: ( handlerOverride?: ( handler: (ctx?: unknown) => DataFunctionReturnValue @@ -339,9 +358,12 @@ export interface DataStrategyMatch ) => Promise; } -export interface DataStrategyFunctionArgs +export interface DataStrategyFunctionArgs extends DataFunctionArgs { matches: DataStrategyMatch[]; + unstable_runClientMiddleware: ( + cb: DataStrategyFunction + ) => Promise>; fetcherKey: string | null; } @@ -353,7 +375,7 @@ export interface DataStrategyResult { result: unknown; // data, Error, Response, DeferredData, DataWithResponseInit } -export interface DataStrategyFunction { +export interface DataStrategyFunction { (args: DataStrategyFunctionArgs): Promise< Record >; @@ -1206,7 +1228,7 @@ export function matchPath< type CompiledPathParam = { paramName: string; isOptional?: boolean }; -function compilePath( +export function compilePath( path: string, caseSensitive = false, end = true diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index a49682f572..ab22c9c2e5 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -5,6 +5,7 @@ import type { RouteManifest, unstable_MiddlewareFunction, } from "../router/utils"; +import { redirectDocument, replace, redirect } from "../router/utils"; import { callRouteHandler } from "./data"; import type { FutureConfig } from "../dom/ssr/entry"; import type { Route } from "../dom/ssr/routes"; @@ -12,7 +13,10 @@ import type { SingleFetchResult, SingleFetchResults, } from "../dom/ssr/single-fetch"; -import { decodeViaTurboStream } from "../dom/ssr/single-fetch"; +import { + SingleFetchRedirectSymbol, + decodeViaTurboStream, +} from "../dom/ssr/single-fetch"; import invariant from "./invariant"; import type { ServerRouteModule } from "../dom/ssr/routeModules"; @@ -99,13 +103,31 @@ export function createStaticHandlerDataRoutes( }); let decoded = await decodeViaTurboStream(stream, global); let data = decoded.value as SingleFetchResults; - invariant( - data && route.id in data, - "Unable to decode prerendered data" - ); - let result = data[route.id] as SingleFetchResult; - invariant("data" in result, "Unable to process prerendered data"); - return result.data; + + // If the loader returned a `.data` redirect, re-throw a normal + // Response here to trigger a document level SSG redirect + if (data && SingleFetchRedirectSymbol in data) { + let result = data[SingleFetchRedirectSymbol]!; + let init = { status: result.status }; + if (result.reload) { + throw redirectDocument(result.redirect, init); + } else if (result.replace) { + throw replace(result.redirect, init); + } else { + throw redirect(result.redirect, init); + } + } else { + invariant( + data && route.id in data, + "Unable to decode prerendered data" + ); + let result = data[route.id] as SingleFetchResult; + invariant( + "data" in result, + "Unable to process prerendered data" + ); + return result.data; + } } let val = await callRouteHandler(route.module.loader!, args); return val; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 0d1aa6a3e1..5cba00bcd4 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -24,18 +24,21 @@ import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; -import type { SingleFetchResult, SingleFetchResults } from "./single-fetch"; import { encodeViaTurboStream, getSingleFetchRedirect, singleFetchAction, singleFetchLoaders, - SingleFetchRedirectSymbol, SINGLE_FETCH_REDIRECT_STATUS, - NO_BODY_STATUS_CODES, + SERVER_NO_BODY_STATUS_CODES, } from "./single-fetch"; import { getDocumentHeaders } from "./headers"; import type { EntryRoute } from "../dom/ssr/routes"; +import type { + SingleFetchResult, + SingleFetchResults, +} from "../dom/ssr/single-fetch"; +import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; import type { MiddlewareEnabled } from "../types/future"; export type RequestHandler = ( @@ -90,12 +93,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( return async function requestHandler(request, initialContext) { _build = typeof build === "function" ? await build() : build; - let loadContext = _build.future.unstable_middleware - ? new unstable_RouterContextProvider( - initialContext as unknown as unstable_InitialContext - ) - : initialContext || {}; - if (typeof build === "function") { let derived = derive(_build, mode); routes = derived.routes; @@ -110,6 +107,44 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( errorHandler = derived.errorHandler; } + let params: RouteMatch["params"] = {}; + let loadContext: AppLoadContext | unstable_RouterContextProvider; + + let handleError = (error: unknown) => { + if (mode === ServerMode.Development) { + getDevServerHooks()?.processRequestError?.(error); + } + + errorHandler(error, { + context: loadContext, + params, + request, + }); + }; + + if (_build.future.unstable_middleware) { + if (initialContext == null) { + loadContext = new unstable_RouterContextProvider(); + } else { + try { + loadContext = new unstable_RouterContextProvider( + initialContext as unknown as unstable_InitialContext + ); + } catch (e) { + let error = new Error( + "Unable to create initial `unstable_RouterContextProvider` instance. " + + "Please confirm you are returning an instance of " + + "`Map` from your `getLoadContext` function." + + `\n\nError: ${e instanceof Error ? e.toString() : e}` + ); + handleError(error); + return returnLastResortErrorResponse(error, serverMode); + } + } + } else { + loadContext = initialContext || {}; + } + let url = new URL(request.url); let normalizedBasename = _build.basename || "/"; @@ -127,19 +162,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( normalizedPath = normalizedPath.slice(0, -1); } - let params: RouteMatch["params"] = {}; - let handleError = (error: unknown) => { - if (mode === ServerMode.Development) { - getDevServerHooks()?.processRequestError?.(error); - } - - errorHandler(error, { - context: loadContext, - params, - request, - }); - }; - // When runtime SSR is disabled, make our dev server behave like the deployed // pre-rendered site would if (!_build.ssr) { @@ -429,7 +451,7 @@ async function handleDocumentRequest( let headers = getDocumentHeaders(build, context); // Skip response body for unsupported status codes - if (NO_BODY_STATUS_CODES.has(context.statusCode)) { + if (SERVER_NO_BODY_STATUS_CODES.has(context.statusCode)) { return new Response(null, { status: context.statusCode, headers }); } diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 145b09f4bc..21c2d84538 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -18,27 +18,16 @@ import type { SingleFetchResult, SingleFetchResults, } from "../dom/ssr/single-fetch"; -import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; +import { + NO_BODY_STATUS_CODES, + SingleFetchRedirectSymbol, +} from "../dom/ssr/single-fetch"; import type { AppLoadContext } from "./data"; import { sanitizeError, sanitizeErrors } from "./errors"; import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; -export type { SingleFetchResult, SingleFetchResults }; -export { SingleFetchRedirectSymbol }; - -// Do not include a response body if the status code is one of these, -// otherwise `undici` will throw an error when constructing the Response: -// https://github.com/nodejs/undici/blob/bd98a6303e45d5e0d44192a93731b1defdb415f3/lib/web/fetch/response.js#L522-L528 -// -// Specs: -// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx -// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content -// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content -// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified -export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]); - // We can't use a 3xx status or else the `fetch()` would follow the redirect. // We need to communicate the redirect back as data so we can act on it in the // client side router. We use a 202 to avoid any automatic caching we might @@ -46,6 +35,14 @@ export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]); // the user control cache behavior via Cache-Control export const SINGLE_FETCH_REDIRECT_STATUS = 202; +// Add 304 for server side - that is not included in the client side logic +// because the browser should fill those responses with the cached data +// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified +export const SERVER_NO_BODY_STATUS_CODES = new Set([ + ...NO_BODY_STATUS_CODES, + 304, +]); + export async function singleFetchAction( build: ServerBuild, serverMode: ServerMode, @@ -280,7 +277,7 @@ function generateSingleFetchResponse( resultHeaders.set("X-Remix-Response", "yes"); // Skip response body for unsupported status codes - if (NO_BODY_STATUS_CODES.has(status)) { + if (SERVER_NO_BODY_STATUS_CODES.has(status)) { return new Response(null, { status, headers: resultHeaders }); } diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 167debc207..add094b2db 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "7.5.0", + "version": "7.5.1", "description": "Declarative routing for React", "keywords": [ "react", @@ -81,7 +81,6 @@ } }, "dependencies": { - "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" diff --git a/playground/framework-rolldown-vite/vite.config.ts b/playground/framework-rolldown-vite/vite.config.ts index fac933f23c..bc98cf6730 100644 --- a/playground/framework-rolldown-vite/vite.config.ts +++ b/playground/framework-rolldown-vite/vite.config.ts @@ -1,11 +1,19 @@ import { reactRouter } from "@react-router/dev/vite"; -import { defineConfig } from "vite"; +import { defineConfig, type UserConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig({ - plugins: [ - // @ts-expect-error `dev` depends on Vite 6, Plugin type is mismatched. - reactRouter(), - tsconfigPaths(), - ], +export default defineConfig(({ isSsrBuild }) => { + const config: UserConfig = { + plugins: [ + // @ts-expect-error `dev` depends on Vite 6, Plugin type is mismatched. + reactRouter(), + tsconfigPaths(), + ], + build: { + // Built-in minifier is still experimental + minify: isSsrBuild ? false : "esbuild", + }, + }; + + return config; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771bb77ae1..ad1676be74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -585,6 +585,76 @@ importers: specifier: ^4.2.0 version: 4.2.0(@cloudflare/workers-types@4.20250317.0) + integration/helpers/vite-rolldown-template: + dependencies: + '@react-router/express': + specifier: workspace:* + version: link:../../../packages/react-router-express + '@react-router/node': + specifier: workspace:* + version: link:../../../packages/react-router-node + '@react-router/serve': + specifier: workspace:* + version: link:../../../packages/react-router-serve + '@vanilla-extract/css': + specifier: ^1.10.0 + version: 1.14.2 + '@vanilla-extract/vite-plugin': + specifier: ^3.9.2 + version: 3.9.5(@types/node@20.11.30)(lightningcss@1.29.3)(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0)) + express: + specifier: ^4.19.2 + version: 4.19.2 + isbot: + specifier: ^5.1.11 + version: 5.1.11 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + react-router: + specifier: workspace:* + version: link:../../../packages/react-router + serialize-javascript: + specifier: ^6.0.1 + version: 6.0.2 + devDependencies: + '@react-router/dev': + specifier: workspace:* + version: link:../../../packages/react-router-dev + '@react-router/fs-routes': + specifier: workspace:* + version: link:../../../packages/react-router-fs-routes + '@react-router/remix-routes-option-adapter': + specifier: workspace:* + version: link:../../../packages/react-router-remix-routes-option-adapter + '@types/react': + specifier: ^18.2.18 + version: 18.2.18 + '@types/react-dom': + specifier: ^18.2.7 + version: 18.2.7 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + eslint: + specifier: ^8.38.0 + version: 8.57.0 + typescript: + specifier: ^5.1.6 + version: 5.4.5 + vite: + specifier: npm:rolldown-vite@6.3.0-beta.5 + version: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + vite-env-only: + specifier: ^3.0.1 + version: 3.0.1(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0)) + vite-tsconfig-paths: + specifier: ^4.2.1 + version: 4.3.2(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0))(typescript@5.4.5) + packages/create-react-router: dependencies: '@remix-run/web-fetch': @@ -663,9 +733,6 @@ importers: packages/react-router: dependencies: - '@types/cookie': - specifier: ^0.6.0 - version: 0.6.0 cookie: specifier: ^1.0.1 version: 1.0.1 @@ -3517,61 +3584,121 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-aq6Y9OQl05bYUnzM4a7ZGF3+Du7cdrw3Ala1eCnvNqxgi2ksXKN+LHvgeaWDlyfLgX0jVQFZre4+kzgLSHEMog==} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-tA3K/yj2MDIKmpMjldEKkS/1k8o8MXIm+bMdLahZmFVRE7ODfQRe3aUaaxTm7wvHG8GKgE4DcqMJTwDeCqAt/g==} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-GRxENhaf92Blo7TZz8C8vBFSt4pCRWDP45ElGATItWqzyM+ILtzNjkE5Wj1OyWPe7y0oWxps6YMxVxEdb3/BJQ==} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-Ps9e395Gmv3nR+WmOLGnN23Qc5R7GZ619QMnrsZZnrNjqts4pf2DAGoPnTY/dCT/z+rfcN3ku35hWh3HsI9XGA==} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-3uibg1KMHT7c149YupfXICxKoO6K7q3MaMpvOdxUjTY9mY3+v96eHTfnq+dd6qD16ppKSVij7FfpEC+sCVDRmg==} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-/RKVSZGQyFpDWI2ksNV7/n2M1bbFvIoS4QvcETU+sMnDfhZQB6vP00dHMFsJS9J+y05XbsMnEgHslrLywFu4Ww==} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-oDFqE2fWx2sj0E9doRUmYxi5TKc9/vmD49NP0dUN578LQO8nJBwqOMvE8bM3a/T3or4g1mlJt2ebzGiKGzjHSw==} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-J6PeOqrX2QttacikU/CcIG2nlsnR9gDTcUQbwEbS1G/DaPrYEHXujiI4YY5Hmd+Sr1IYXI9i3z/RfzRI9XmcpQ==} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-0Weogg1WiFNkmwznM4YS4AmDa55miGstb/I4zKquIsv1kSBLBkxboifgWTCPUnGFK7Wy1u/beRnxCY7UVL1oPw==} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-lMUOKYcdDxpZMvkMbznjkqikPnvo3UIpdEfzEMp2/rOlYyC/2p3Trg3kGjhF4lbfRLbbuPEjLepGf67ot0I8oQ==} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-LwEN10APipzjxAHSreVTEnUAHRz3sq4/UR3IVD/AguV0s6yAbVAsIrvIoxXHKoci+RUlyY5FXICYZQKli8iU5w==} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-ydsgeyhu3/AvB+I1/+uQ1+PSEQRmftkvJ1ewoXB0oJTozAKN6Ywx8jnmV8jA1g/IuMDzepR6/ixF0hbyYinWWQ==} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-tgE2J4BAQfI0rfoPzS4r1LEHSNxdNSM8l1Ab5InnzE4dXzVw92BVQ/FLFE6L+nWy81O7uwd7yz0Jo+qByOPCXg==} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-prSpmuIoS6M1KLRd2Fzpz9n6K6K8g8/F5bN15iEpjRZCkCOI24+bVX6fDKbI0frstIMzFVvbGSxmHxt0pyphEA==} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-m78svPie3D5PIBxmexztDVHjrnHO5t6h3Lwtl6sqdrio1zhGYMY9FcPcaZZ40mXXWKHFoPmbueBZZLdttvOEIQ==} + cpu: [x64] + os: [linux] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-kRFr1jOfL4L627d1Bw/EPst3A2BwP+DV6CH/Myxl88DFzAeOAfQ04hFfCm8lBcRxzfrJNcFAMNrdIKgdUd7ddQ==} engines: {node: '>=14.21.3'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-XbOcOWmdioNZ3hHBb5j96Y9S9pGyTeFZWR5ovMZggA9L7mWft2pMrbx4p5zUy2dCps3l1jaFQCjKuBXpwoCZug==} + engines: {node: '>=14.21.3'} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-1l+vls3mjcKOxsrnwcwG1fX8/pL7URuZ+d+7WvKaXXIq3Id6HSdtCYuBwkUg3Bdm0mLDk7Qyv1QG3BwTcFahGQ==} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-lnZ/wrat6UMWGbS9w5AUEH8WkPBA4EUSYp8cxlspdU16dISeD/KGpF2d0hS6Oa6ftbgZZrRLMEnQRiD8OupPsg==} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-YJxvaPtH4sl5reLZCvNuqFHCgdsIRGG77LET+xng9CEWGaA1Epx2qcbeAAX8czU82tYrorx5Taxioo3GqvF53w==} cpu: [ia32] os: [win32] + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-F0N/6kAnCl9dOgqR09T60UjQSxKvRtlbImhiYxIdKBFxgYDDGsh8XzlSbMRUVQmMtNwKC8xi+i+SnamSqY6q8Q==} + cpu: [ia32] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.7452fa0': resolution: {integrity: sha512-OZok4v+44zYlSqo5pVyt5xPgruYcaPig9T0ieOh+O7f3BWqlkLI3ZFalznq2zFp4mJS7GtrqOAm6h7sgd+LTOw==} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.e117288': + resolution: {integrity: sha512-T3qKMkSVemlVLLd5V7dCXnjt4Zda1UnUi45AQnmxIf3jH0/VP0J4aYAJiEEaRbhMoHc82j01+6MuZFZUVMeqng==} + cpu: [x64] + os: [win32] + '@rollup/pluginutils@5.1.0': resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} engines: {node: '>=14.0.0'} @@ -3856,9 +3983,6 @@ packages: '@types/cookie@0.4.1': resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -7821,6 +7945,46 @@ packages: yaml: optional: true + rolldown-vite@6.3.0-beta.5: + resolution: {integrity: sha512-/seCUlTV3pHNn0Y8qveGmHMNYxH/Z9xc65Ov0uaA/HtThaMZNTacWsMyDG4SA+S/c1RdpWIe85E5NeOmhywrGg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: '*' + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + rolldown@1.0.0-beta.7-commit.7452fa0: resolution: {integrity: sha512-6/poOMpWJUy+MEd7qt6/f5lOOepR7vUXtMuK+J494yVA6jtkyXlCScvLVytpo13AKx+IhW/wt6qpCaZdFasd0g==} hasBin: true @@ -7830,6 +7994,15 @@ packages: '@oxc-project/runtime': optional: true + rolldown@1.0.0-beta.7-commit.e117288: + resolution: {integrity: sha512-3pjhtA9BV/q9cNdcz75ehvie3lgFfJZfzIT8A7aZJPvFCaWTj5AUAlcExXRWO/CIMMZ/49Y1x3MTwRC/Q/LuAw==} + hasBin: true + peerDependencies: + '@oxc-project/runtime': 0.61.2 + peerDependenciesMeta: + '@oxc-project/runtime': + optional: true + rollup@4.24.0: resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -11194,41 +11367,79 @@ snapshots: '@rolldown/binding-darwin-arm64@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.7452fa0': dependencies: '@napi-rs/wasm-runtime': 0.2.7 optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.7-commit.e117288': + dependencies: + '@napi-rs/wasm-runtime': 0.2.7 + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.7-commit.e117288': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.7452fa0': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.7-commit.e117288': + optional: true + '@rollup/pluginutils@5.1.0(rollup@4.34.8)': dependencies: '@types/estree': 1.0.6 @@ -11477,8 +11688,6 @@ snapshots: '@types/cookie@0.4.1': {} - '@types/cookie@0.6.0': {} - '@types/cookiejar@2.1.5': {} '@types/cross-spawn@6.0.6': @@ -11954,6 +12163,24 @@ snapshots: '@vanilla-extract/private@1.0.4': {} + '@vanilla-extract/vite-plugin@3.9.5(@types/node@20.11.30)(lightningcss@1.29.3)(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0))': + dependencies: + '@vanilla-extract/integration': 6.5.0(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0) + outdent: 0.8.0 + postcss: 8.4.49 + postcss-load-config: 4.0.2(postcss@8.4.49) + vite: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + - ts-node + '@vanilla-extract/vite-plugin@3.9.5(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0)(vite@5.1.3(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0))': dependencies: '@vanilla-extract/integration': 6.5.0(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0) @@ -16627,6 +16854,24 @@ snapshots: transitivePeerDependencies: - typescript + rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0): + dependencies: + '@oxc-project/runtime': 0.61.2 + lightningcss: 1.29.3 + picomatch: 4.0.2 + postcss: 8.5.3 + rolldown: 1.0.0-beta.7-commit.e117288(@oxc-project/runtime@0.61.2)(typescript@5.4.5) + tinyglobby: 0.2.12 + optionalDependencies: + '@types/node': 20.11.30 + esbuild: 0.25.0 + fsevents: 2.3.3 + jiti: 1.21.0 + tsx: 4.19.3 + yaml: 2.6.0 + transitivePeerDependencies: + - typescript + rolldown@1.0.0-beta.7-commit.7452fa0(@oxc-project/runtime@0.61.2)(typescript@5.4.5): dependencies: '@oxc-project/types': 0.61.2 @@ -16649,6 +16894,28 @@ snapshots: transitivePeerDependencies: - typescript + rolldown@1.0.0-beta.7-commit.e117288(@oxc-project/runtime@0.61.2)(typescript@5.4.5): + dependencies: + '@oxc-project/types': 0.61.2 + '@valibot/to-json-schema': 1.0.0(valibot@1.0.0(typescript@5.4.5)) + valibot: 1.0.0(typescript@5.4.5) + optionalDependencies: + '@oxc-project/runtime': 0.61.2 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-darwin-x64': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.7-commit.e117288 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.7-commit.e117288 + transitivePeerDependencies: + - typescript + rollup@4.24.0: dependencies: '@types/estree': 1.0.6 @@ -17648,6 +17915,19 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 + vite-env-only@3.0.1(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0)): + dependencies: + '@babel/core': 7.24.3 + '@babel/generator': 7.24.1 + '@babel/parser': 7.24.1 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + babel-dead-code-elimination: 1.0.6 + micromatch: 4.0.5 + vite: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + transitivePeerDependencies: + - supports-color + vite-env-only@3.0.1(vite@5.1.3(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0)): dependencies: '@babel/core': 7.24.3 @@ -17723,6 +18003,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@4.3.2(rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0))(typescript@5.4.5): + dependencies: + debug: 4.3.7 + globrex: 0.1.2 + tsconfck: 3.0.3(typescript@5.4.5) + optionalDependencies: + vite: rolldown-vite@6.3.0-beta.5(@types/node@20.11.30)(esbuild@0.25.0)(jiti@1.21.0)(tsx@4.19.3)(typescript@5.4.5)(yaml@2.6.0) + transitivePeerDependencies: + - supports-color + - typescript + vite-tsconfig-paths@4.3.2(typescript@5.4.5)(vite@5.1.3(@types/node@20.11.30)(lightningcss@1.29.3)(terser@5.15.0)): dependencies: debug: 4.3.7 diff --git a/scripts/start-prerelease.sh b/scripts/start-prerelease.sh new file mode 100755 index 0000000000..9309e8414d --- /dev/null +++ b/scripts/start-prerelease.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -x +set -e + +if [[ -n $(git status --porcelain) ]]; then + echo "Error: Your git working directory is not clean. Please commit or stash your changes." + exit 1 +fi + +git checkout main +git pull +git checkout dev +git pull +git checkout -b release-next +git merge main --no-edit +pnpm changeset pre enter pre +git add .changeset/pre.json +git commit -m "Enter prerelease mode" +git push --set-upstream origin release-next + +set +e +set +x \ No newline at end of file