diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4451730d..25832711c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,173 +13,175 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) + - [v6.26.1](#v6261) + - [Patch Changes](#patch-changes) - [v6.26.0](#v6260) - [Minor Changes](#minor-changes) - - [Patch Changes](#patch-changes) - - [v6.25.1](#v6251) - [Patch Changes](#patch-changes-1) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-2) - [v6.25.0](#v6250) - [What's Changed](#whats-changed) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - [Minor Changes](#minor-changes-1) - - [Patch Changes](#patch-changes-2) - - [v6.24.1](#v6241) - [Patch Changes](#patch-changes-3) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-4) - [v6.24.0](#v6240) - [What's Changed](#whats-changed-1) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - [Minor Changes](#minor-changes-2) - - [Patch Changes](#patch-changes-4) - - [v6.23.1](#v6231) - [Patch Changes](#patch-changes-5) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-6) - [v6.23.0](#v6230) - [What's Changed](#whats-changed-2) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - [Minor Changes](#minor-changes-3) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-6) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-7) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-8) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-9) - [v6.22.0](#v6220) - [What's Changed](#whats-changed-3) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - [Minor Changes](#minor-changes-4) - - [Patch Changes](#patch-changes-9) - - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-10) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-11) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-12) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-13) - [v6.21.0](#v6210) - [What's Changed](#whats-changed-4) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - [Minor Changes](#minor-changes-5) - - [Patch Changes](#patch-changes-13) - - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-14) + - [v6.20.1](#v6201) + - [Patch Changes](#patch-changes-15) - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-6) - - [Patch Changes](#patch-changes-15) + - [Patch Changes](#patch-changes-16) - [v6.19.0](#v6190) - [What's Changed](#whats-changed-5) - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-7) - - [Patch Changes](#patch-changes-16) + - [Patch Changes](#patch-changes-17) - [v6.18.0](#v6180) - [What's Changed](#whats-changed-6) - [New Fetcher APIs](#new-fetcher-apis) - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-8) - - [Patch Changes](#patch-changes-17) + - [Patch Changes](#patch-changes-18) - [v6.17.0](#v6170) - [What's Changed](#whats-changed-7) - [View Transitions 🚀](#view-transitions-) - [Minor Changes](#minor-changes-9) - - [Patch Changes](#patch-changes-18) + - [Patch Changes](#patch-changes-19) - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-10) - - [Patch Changes](#patch-changes-19) + - [Patch Changes](#patch-changes-20) - [v6.15.0](#v6150) - [Minor Changes](#minor-changes-11) - - [Patch Changes](#patch-changes-20) - - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-21) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-22) + - [v6.14.1](#v6141) + - [Patch Changes](#patch-changes-23) - [v6.14.0](#v6140) - [What's Changed](#whats-changed-8) - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-23) + - [Patch Changes](#patch-changes-24) - [v6.13.0](#v6130) - [What's Changed](#whats-changed-9) - [`future.v7_startTransition`](#futurev7_starttransition) - [Minor Changes](#minor-changes-13) - - [Patch Changes](#patch-changes-24) - - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-25) + - [v6.12.1](#v6121) + - [Patch Changes](#patch-changes-26) - [v6.12.0](#v6120) - [What's Changed](#whats-changed-10) - [`React.startTransition` support](#reactstarttransition-support) - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-26) - - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-27) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-28) + - [v6.11.1](#v6111) + - [Patch Changes](#patch-changes-29) - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-29) + - [Patch Changes](#patch-changes-30) - [v6.10.0](#v6100) - [What's Changed](#whats-changed-11) - [Minor Changes](#minor-changes-16) - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - - [Patch Changes](#patch-changes-30) + - [Patch Changes](#patch-changes-31) - [v6.9.0](#v690) - [What's Changed](#whats-changed-12) - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-17) - - [Patch Changes](#patch-changes-31) - - [v6.8.2](#v682) - [Patch Changes](#patch-changes-32) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-33) + - [v6.8.1](#v681) + - [Patch Changes](#patch-changes-34) - [v6.8.0](#v680) - [Minor Changes](#minor-changes-18) - - [Patch Changes](#patch-changes-34) + - [Patch Changes](#patch-changes-35) - [v6.7.0](#v670) - [Minor Changes](#minor-changes-19) - - [Patch Changes](#patch-changes-35) - - [v6.6.2](#v662) - [Patch Changes](#patch-changes-36) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-37) + - [v6.6.1](#v661) + - [Patch Changes](#patch-changes-38) - [v6.6.0](#v660) - [What's Changed](#whats-changed-13) - [Minor Changes](#minor-changes-20) - - [Patch Changes](#patch-changes-38) + - [Patch Changes](#patch-changes-39) - [v6.5.0](#v650) - [What's Changed](#whats-changed-14) - [Minor Changes](#minor-changes-21) - - [Patch Changes](#patch-changes-39) - - [v6.4.5](#v645) - [Patch Changes](#patch-changes-40) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-41) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-42) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-43) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-44) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-45) - [v6.4.0](#v640) - [What's Changed](#whats-changed-15) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-45) + - [Patch Changes](#patch-changes-46) - [v6.3.0](#v630) - [Minor Changes](#minor-changes-22) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-46) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-47) + - [v6.2.1](#v621) + - [Patch Changes](#patch-changes-48) - [v6.2.0](#v620) - [Minor Changes](#minor-changes-23) - - [Patch Changes](#patch-changes-48) - - [v6.1.1](#v611) - [Patch Changes](#patch-changes-49) + - [v6.1.1](#v611) + - [Patch Changes](#patch-changes-50) - [v6.1.0](#v610) - [Minor Changes](#minor-changes-24) - - [Patch Changes](#patch-changes-50) - - [v6.0.2](#v602) - [Patch Changes](#patch-changes-51) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-52) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-53) - [v6.0.0](#v600) @@ -203,6 +205,18 @@ Date: YYYY-MM-DD **Full Changelog**: [`v6.X.Y...v6.X.Y`](https://github.com/remix-run/react-router/compare/react-router@6.X.Y...react-router@6.X.Y) --> +## v6.26.1 + +Date: 2024-08-15 + +### Patch Changes + +- Rename `unstable_patchRoutesOnMiss` to `unstable_patchRoutesOnNavigation` to match new behavior ([#11888](https://github.com/remix-run/react-router/pull/11888)) +- Update `unstable_patchRoutesOnNavigation` logic so that we call the method when we match routes with dynamic param or splat segments in case there exists a higher-scoring static route that we've not yet discovered ([#11883](https://github.com/remix-run/react-router/pull/11883)) + - We also now leverage an internal FIFO queue of previous paths we've already called `unstable_patchRoutesOnNavigation` against so that we don't re-call on subsequent navigations to the same path + +**Full Changelog**: [`v6.26.0...v6.26.1`](https://github.com/remix-run/react-router/compare/react-router@6.26.0...react-router@6.26.1) + ## v6.26.0 Date: 2024-08-01 diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts index 4e171e50fd..652e4f8e61 100644 --- a/integration/fog-of-war-test.ts +++ b/integration/fog-of-war-test.ts @@ -692,6 +692,7 @@ test.describe("Fog of War", () => { "routes/parent._a._b", "routes/parent._a._b._index", ]); + expect(manifestRequests).toEqual([]); // Without pre-loading the index, we'd "match" `/parent` to just the // parent route client side and never fetch the children pathless/index routes @@ -715,6 +716,444 @@ test.describe("Fog of War", () => { ]); }); + test("detects higher-ranking static routes on the server when a slug match is already known by the client", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/$slug.tsx": js` + import { Link } from "react-router"; + export default function Component() { + return ( + <> +

Slug

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

Static

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$slug"]); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fsomething/), + ]); + manifestRequests = []; + + await app.clickLink("/something"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug"); + expect(manifestRequests).toEqual([]); + + // This will require a new fetch for the /static route + await app.clickLink("/static"); + await page.waitForSelector("#static"); + expect(await app.getHtml("#static")).toMatch("Static"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fstatic/), + ]); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$slug", "routes/static"]); + }); + + test("detects higher-ranking static routes on the server when a splat match is already known by the client", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/$.tsx": js` + import { Link } from "react-router"; + export default function Component() { + return ( + <> +

Splat

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

Static

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$"]); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fsomething/), + ]); + manifestRequests = []; + + await app.clickLink("/something"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat"); + expect(manifestRequests).toEqual([]); + + // This will require a new fetch for the /static route + await app.clickLink("/static"); + await page.waitForSelector("#static"); + expect(await app.getHtml("#static")).toMatch("Static"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fstatic/), + ]); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$", "routes/static"]); + }); + + test("does not re-request for previously discovered slug routes", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/routes/$slug.tsx": js` + import { Link, useParams } from "react-router"; + export default function Component() { + let params = useParams(); + return

Slug: {params.slug}

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + expect(manifestRequests.length).toBe(0); + + // Click /a which will discover via a manifest request + await app.clickLink("/a"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug: a"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fa/), + ]); + manifestRequests = []; + + // Go back home + await app.clickLink("/"); + await page.waitForSelector("#index"); + expect(manifestRequests).toEqual([]); + + // Click /a again which will not re-discover + await app.clickLink("/a"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug: a"); + expect(manifestRequests).toEqual([]); + manifestRequests = []; + + // Click /b which will need to discover + await app.clickLink("/b"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug: b"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fb/), + ]); + }); + + test("does not re-request for previously discovered splat routes", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/routes/$.tsx": js` + import { Link, useParams } from "react-router"; + export default function Component() { + let params = useParams(); + return

Splat: {params["*"]}

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + expect(manifestRequests.length).toBe(0); + + // Click /a which will discover via a manifest request + await app.clickLink("/a"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat: a"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fa/), + ]); + manifestRequests = []; + + // Go back home + await app.clickLink("/"); + await page.waitForSelector("#index"); + expect(manifestRequests).toEqual([]); + + // Click /a again which will not re-discover + await app.clickLink("/a"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat: a"); + expect(manifestRequests).toEqual([]); + manifestRequests = []; + + // Click /b which will need to discover + await app.clickLink("/b/c"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat: b/c"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fb%2Fc/), + ]); + }); + + test("does not re-request for previously navigated 404 routes", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export function Layout({ children }) { + return ( + + + + + {children} + + + + ); + } + export default function Root() { + return ; + } + export function ErrorBoundary() { + return

Error

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

Index

; + } + `, + "app/routes/$slug.tsx": js` + import { Link, useParams } from "react-router"; + export default function Component() { + let params = useParams(); + return

Slug: {params.slug}

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + expect(manifestRequests.length).toBe(0); + + // Click a 404 link which will try to discover via a manifest request + await app.clickLink("/not/a/path"); + await page.waitForSelector("#error"); + expect(manifestRequests).toEqual([ + expect.stringMatching( + /\/__manifest\?version=[a-z0-9]{8}&p=%2Fnot%2Fa%2Fpath/ + ), + ]); + manifestRequests = []; + + // Go to a valid slug route + await app.clickLink("/something"); + await page.waitForSelector("#slug"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?version=[a-z0-9]{8}&p=%2Fsomething/), + ]); + manifestRequests = []; + + // Click the same 404 link again which will not re-discover + await app.clickLink("/not/a/path"); + await page.waitForSelector("#error"); + expect(manifestRequests).toEqual([]); + }); + test("skips prefetching if the URL gets too large", async ({ page }) => { let fixture = await createFixture({ files: { diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index a2ef2570f1..f219bb543e 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -1350,6 +1350,67 @@ test.describe("single-fetch", () => { expect(await app.getHtml("#target")).toContain("Target"); }); + test("processes redirects when a basename is present", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...files, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@react-router/dev"; + export default defineConfig({ + plugins: [ + remix({ + basename: '/base', + }), + ], + }); + `, + "app/routes/data.tsx": js` + import { redirect } from 'react-router'; + export function loader() { + throw redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + + console.error = () => {}; + + let res = await fixture.requestDocument("/base/data"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/base/target"); + expect(await res.text()).toBe(""); + + let { status, data } = await fixture.requestSingleFetchData( + "/base/data.data" + ); + expect(data).toEqual({ + [SingleFetchRedirectSymbol]: { + status: 302, + redirect: "/target", + reload: false, + replace: false, + revalidate: false, + }, + }); + expect(status).toBe(202); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/base/"); + await app.clickLink("/base/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + test("processes thrown loader errors", async ({ page }) => { let fixture = await createFixture({ files: { diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 9c4b1fa19a..dbde50bfec 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,13 @@ # `react-router` +## 6.26.1 + +### Patch Changes + +- Rename `unstable_patchRoutesOnMiss` to `unstable_patchRoutesOnNavigation` to match new behavior ([#11888](https://github.com/remix-run/react-router/pull/11888)) +- Updated dependencies: + - `@remix-run/router@1.19.1` + ## 6.26.0 ### Minor Changes @@ -15,9 +23,7 @@ ## 6.25.1 -### Patch Changes - -- Memoize some `RouterProvider` internals to reduce unnecessary re-renders ([#11803](https://github.com/remix-run/react-router/pull/11803)) +No significant changes to this package were made in this release. [See the repo `CHANGELOG.md`](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md) for an overview of all changes in v6.25.1. ## 6.25.0 diff --git a/packages/react-router/__tests__/dom/partial-hydration-test.tsx b/packages/react-router/__tests__/dom/partial-hydration-test.tsx index f3fb697ae3..dbd6926f20 100644 --- a/packages/react-router/__tests__/dom/partial-hydration-test.tsx +++ b/packages/react-router/__tests__/dom/partial-hydration-test.tsx @@ -32,7 +32,7 @@ describe("Partial Hydration Behavior", () => { testPartialHydration(createMemoryRouter, ReactRouter_RouterProvider); // these tests only run for memory since we just need to set initialEntries - it("supports partial hydration w/patchRoutesOnMiss (leaf fallback)", async () => { + it("supports partial hydration w/patchRoutesOnNavigation (leaf fallback)", async () => { let parentDfd = createDeferred(); let childDfd = createDeferred(); let router = createMemoryRouter( @@ -70,7 +70,7 @@ describe("Partial Hydration Behavior", () => { future: { v7_partialHydration: true, }, - unstable_patchRoutesOnMiss({ path, patch }) { + unstable_patchRoutesOnNavigation({ path, patch }) { if (path === "/parent/child") { patch("parent", [ { @@ -120,7 +120,7 @@ describe("Partial Hydration Behavior", () => { `); }); - it("supports partial hydration w/patchRoutesOnMiss (root fallback)", async () => { + it("supports partial hydration w/patchRoutesOnNavigation (root fallback)", async () => { let parentDfd = createDeferred(); let childDfd = createDeferred(); let router = createMemoryRouter( @@ -158,7 +158,7 @@ describe("Partial Hydration Behavior", () => { future: { v7_partialHydration: true, }, - unstable_patchRoutesOnMiss({ path, patch }) { + unstable_patchRoutesOnNavigation({ path, patch }) { if (path === "/parent/child") { patch("parent", [ { diff --git a/packages/react-router/__tests__/router/lazy-discovery-test.ts b/packages/react-router/__tests__/router/lazy-discovery-test.ts index 6a1c1b0da7..03e45e9092 100644 --- a/packages/react-router/__tests__/router/lazy-discovery-test.ts +++ b/packages/react-router/__tests__/router/lazy-discovery-test.ts @@ -36,7 +36,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { loader: () => loaderDfd.promise, }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -92,7 +92,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ patch, matches }) { + async unstable_patchRoutesOnNavigation({ patch, matches }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -148,7 +148,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { loader: () => loaderDfd.promise, }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -222,7 +222,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ patch, matches }) { + async unstable_patchRoutesOnNavigation({ patch, matches }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -286,7 +286,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ path, matches, patch }) { + async unstable_patchRoutesOnNavigation({ path, matches, patch }) { let routeId = last(matches).route.id; calls.push([path, routeId]); patch("a", await aDfd.promise); @@ -341,7 +341,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ path, matches, patch }) { + async unstable_patchRoutesOnNavigation({ path, matches, patch }) { let routeId = last(matches).route.id; if (!path) { return; @@ -444,7 +444,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { }, }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { let leafRoute = last(matches).route; patch(leafRoute.id, await leafRoute.handle.loadChildren?.()); }, @@ -474,7 +474,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -510,6 +510,121 @@ describe("Lazy Route Discovery (Fog of War)", () => { ]); }); + it("de-prioritizes dynamic param routes in favor of looking for better async matches", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "slug", + path: "/:slug", + }, + ], + async unstable_patchRoutesOnNavigation({ patch }) { + await tick(); + patch(null, [ + { + id: "static", + path: "/static", + }, + ]); + }, + }); + + await router.navigate("/static"); + expect(router.state.location.pathname).toBe("/static"); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["static"]); + }); + + it("de-prioritizes dynamic param routes in favor of looking for better async matches (product/:slug)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "slug", + path: "/product/:slug", + }, + ], + async unstable_patchRoutesOnNavigation({ patch }) { + await tick(); + patch(null, [ + { + id: "static", + path: "/product/static", + }, + ]); + }, + }); + + await router.navigate("/product/static"); + expect(router.state.location.pathname).toBe("/product/static"); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["static"]); + }); + + it("de-prioritizes dynamic param routes in favor of looking for better async matches (child route)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "product", + path: "/product", + children: [ + { + id: "slug", + path: ":slug", + }, + ], + }, + ], + async unstable_patchRoutesOnNavigation({ patch }) { + await tick(); + patch("product", [ + { + id: "static", + path: "static", + }, + ]); + }, + }); + + await router.navigate("/product/static"); + expect(router.state.location.pathname).toBe("/product/static"); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "product", + "static", + ]); + }); + + it("matches dynamic params when other paths don't pan out", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "slug", + path: "/:slug", + }, + ], + async unstable_patchRoutesOnNavigation({ matches, patch }) { + await tick(); + }, + }); + + await router.navigate("/a"); + expect(router.state.location.pathname).toBe("/a"); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["slug"]); + }); + it("de-prioritizes splat routes in favor of looking for better async matches", async () => { router = createRouter({ history: createMemoryHistory(), @@ -526,7 +641,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -556,7 +671,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "/splat/*", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); patch(null, [ { @@ -572,6 +687,43 @@ describe("Lazy Route Discovery (Fog of War)", () => { expect(router.state.matches.map((m) => m.route.id)).toEqual(["static"]); }); + it("de-prioritizes splat routes in favor of looking for better async matches (child route)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "product", + path: "/product", + children: [ + { + id: "splat", + path: "*", + }, + ], + }, + ], + async unstable_patchRoutesOnNavigation({ patch }) { + await tick(); + patch("product", [ + { + id: "static", + path: "static", + }, + ]); + }, + }); + + await router.navigate("/product/static"); + expect(router.state.location.pathname).toBe("/product/static"); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "product", + "static", + ]); + }); + it("matches splats when other paths don't pan out", async () => { router = createRouter({ history: createMemoryHistory(), @@ -588,7 +740,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -606,6 +758,50 @@ describe("Lazy Route Discovery (Fog of War)", () => { expect(router.state.matches.map((m) => m.route.id)).toEqual(["splat"]); }); + it("recurses unstable_patchRoutesOnNavigation until a match is found", async () => { + let count = 0; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnNavigation({ matches, patch }) { + await tick(); + count++; + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } else if (last(matches).route.id === "b") { + patch("b", [ + { + id: "c", + path: "c", + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c"); + expect(router.state.location.pathname).toBe("/a/b/c"); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + expect(count).toBe(2); + }); + it("discovers routes during initial hydration", async () => { let childrenDfd = createDeferred(); let loaderDfd = createDeferred(); @@ -623,7 +819,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { loader: () => loaderDfd.promise, }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -674,7 +870,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "*", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch(null, children); }, @@ -712,7 +908,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { splat: "SPLAT 1", }, }, - async unstable_patchRoutesOnMiss() { + async unstable_patchRoutesOnNavigation() { throw new Error("Should not be called"); }, }); @@ -739,7 +935,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "/parent", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { patch(null, await childrenDfd.promise); }, }); @@ -790,7 +986,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "/:param", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { // We matched for the param but we want to patch in under root expect(matches.length).toBe(1); expect(matches[0].route.id).toBe("param"); @@ -845,7 +1041,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "*", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { // We matched for the splat but we want to patch in at the top expect(matches.length).toBe(1); expect(matches[0].route.id).toBe("splat"); @@ -895,7 +1091,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "/nope", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { expect(matches.length).toBe(0); let children = await childrenDfd.promise; patch(null, children); @@ -946,7 +1142,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "parent", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -1006,6 +1202,136 @@ describe("Lazy Route Discovery (Fog of War)", () => { unsubscribe(); }); + it('does not re-call for previously called "good" paths', async () => { + let count = 0; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "param", + path: ":param", + }, + ], + async unstable_patchRoutesOnNavigation() { + count++; + await tick(); + // Nothing to patch - there is no better static route in this case + }, + }); + + await router.navigate("/whatever"); + expect(count).toBe(1); + expect(router.state.location.pathname).toBe("/whatever"); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["param"]); + + await router.navigate("/"); + expect(count).toBe(1); + expect(router.state.location.pathname).toBe("/"); + + await router.navigate("/whatever"); + expect(count).toBe(1); // Not called again + expect(router.state.location.pathname).toBe("/whatever"); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["param"]); + }); + + it("does not re-call for previously called 404 paths", async () => { + let count = 0; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + id: "index", + path: "/", + }, + { + id: "static", + path: "static", + }, + ], + async unstable_patchRoutesOnNavigation() { + count++; + }, + }); + + await router.navigate("/junk"); + expect(count).toBe(1); + expect(router.state.location.pathname).toBe("/junk"); + expect(router.state.errors?.index).toEqual( + new ErrorResponseImpl( + 404, + "Not Found", + new Error('No route matches URL "/junk"'), + true + ) + ); + + await router.navigate("/"); + expect(count).toBe(1); + expect(router.state.location.pathname).toBe("/"); + expect(router.state.errors).toBeNull(); + + await router.navigate("/junk"); + expect(count).toBe(1); + expect(router.state.location.pathname).toBe("/junk"); + expect(router.state.errors?.index).toEqual( + new ErrorResponseImpl( + 404, + "Not Found", + new Error('No route matches URL "/junk"'), + true + ) + ); + }); + + it("caps internal fifo queue at 1000 paths", async () => { + let count = 0; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "param", + path: ":param", + }, + ], + async unstable_patchRoutesOnNavigation() { + count++; + // Nothing to patch - there is no better static route in this case + }, + }); + + // Fill it up with 1000 paths + for (let i = 1; i <= 1000; i++) { + await router.navigate(`/path-${i}`); + expect(count).toBe(i); + expect(router.state.location.pathname).toBe(`/path-${i}`); + + await router.navigate("/"); + expect(count).toBe(i); + expect(router.state.location.pathname).toBe("/"); + } + + // Don't call patchRoutesOnNavigation since this is the first item in the queue + await router.navigate(`/path-1`); + expect(count).toBe(1000); + expect(router.state.location.pathname).toBe(`/path-1`); + + // Call patchRoutesOnNavigation and evict the first item + await router.navigate(`/path-1001`); + expect(count).toBe(1001); + expect(router.state.location.pathname).toBe(`/path-1001`); + + // Call patchRoutesOnNavigation since this item was evicted + await router.navigate(`/path-1`); + expect(count).toBe(1002); + expect(router.state.location.pathname).toBe(`/path-1`); + }); + describe("errors", () => { it("lazy 404s (GET navigation)", async () => { let childrenDfd = createDeferred(); @@ -1021,7 +1347,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "parent", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -1076,7 +1402,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "parent", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -1133,7 +1459,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -1186,7 +1512,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -1239,7 +1565,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -1291,7 +1617,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -1348,7 +1674,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -1405,7 +1731,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -1449,7 +1775,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { ]); }); - it("handles errors thrown from patchRoutesOnMiss() (GET navigation)", async () => { + it("handles errors thrown from patchRoutesOnNavigation() (GET navigation)", async () => { let shouldThrow = true; router = createRouter({ history: createMemoryHistory(), @@ -1463,7 +1789,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { await tick(); if (shouldThrow) { shouldThrow = false; @@ -1491,7 +1817,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { 400, "Bad Request", new Error( - 'Unable to match URL "/a/b" - the `unstable_patchRoutesOnMiss()` ' + + 'Unable to match URL "/a/b" - the `unstable_patchRoutesOnNavigation()` ' + "function threw the following error:\nError: broke!" ), true @@ -1521,7 +1847,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { expect(router.state.matches.map((m) => m.route.id)).toEqual(["a", "b"]); }); - it("handles errors thrown from patchRoutesOnMiss() (POST navigation)", async () => { + it("handles errors thrown from patchRoutesOnNavigation() (POST navigation)", async () => { let shouldThrow = true; router = createRouter({ history: createMemoryHistory(), @@ -1535,7 +1861,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { await tick(); if (shouldThrow) { shouldThrow = false; @@ -1566,7 +1892,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { 400, "Bad Request", new Error( - 'Unable to match URL "/a/b" - the `unstable_patchRoutesOnMiss()` ' + + 'Unable to match URL "/a/b" - the `unstable_patchRoutesOnNavigation()` ' + "function threw the following error:\nError: broke!" ), true @@ -1599,7 +1925,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { expect(router.state.matches.map((m) => m.route.id)).toEqual(["a", "b"]); }); - it("bubbles errors thrown from patchRoutesOnMiss() during hydration", async () => { + it("bubbles errors thrown from patchRoutesOnNavigation() during hydration", async () => { router = createRouter({ history: createMemoryHistory({ initialEntries: ["/parent/child/grandchild"], @@ -1617,7 +1943,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { ], }, ], - async unstable_patchRoutesOnMiss() { + async unstable_patchRoutesOnNavigation() { await tick(); throw new Error("broke!"); }, @@ -1644,7 +1970,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { "Bad Request", new Error( 'Unable to match URL "/parent/child/grandchild" - the ' + - "`unstable_patchRoutesOnMiss()` function threw the following " + + "`unstable_patchRoutesOnNavigation()` function threw the following " + "error:\nError: broke!" ), true @@ -1674,7 +2000,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "parent", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -1713,7 +2039,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ @@ -1761,7 +2087,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "parent", }, ], - async unstable_patchRoutesOnMiss({ patch }) { + async unstable_patchRoutesOnNavigation({ patch }) { let children = await childrenDfd.promise; patch("parent", children); }, @@ -1803,7 +2129,7 @@ describe("Lazy Route Discovery (Fog of War)", () => { path: "a", }, ], - async unstable_patchRoutesOnMiss({ matches, patch }) { + async unstable_patchRoutesOnNavigation({ matches, patch }) { await tick(); if (last(matches).route.id === "a") { patch("a", [ diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index d4efd5c92e..cb5e2dc842 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -93,7 +93,7 @@ export type { RouterProps, RouterProviderProps, RoutesProps, - PatchRoutesOnMissFunction as unstable_PatchRoutesOnMissFunction, + PatchRoutesOnNavigationFunction as unstable_PatchRoutesOnNavigationFunction, } from "./lib/components"; export type { NavigateFunction } from "./lib/hooks"; export { @@ -374,7 +374,7 @@ export { RemixErrorBoundary as UNSAFE_RemixErrorBoundary } from "./lib/dom/ssr/e /** @internal */ export { - initFogOfWar as UNSAFE_initFogOfWar, + getPatchRoutesOnNavigationFunction as UNSAFE_getPatchRoutesOnNavigationFunction, useFogOFWarDiscovery as UNSAFE_useFogOFWarDiscovery, } from "./lib/dom/ssr/fog-of-war"; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index c923dc1dbe..bae812fb35 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -23,7 +23,7 @@ import type { } from "./router/router"; import { createRouter } from "./router/router"; import type { - AgnosticPatchRoutesOnMissFunction, + AgnosticPatchRoutesOnNavigationFunction, DataStrategyFunction, LazyRouteFunction, TrackedPromise, @@ -130,8 +130,8 @@ export function mapRouteProperties(route: RouteObject) { return updates; } -export interface PatchRoutesOnMissFunction - extends AgnosticPatchRoutesOnMissFunction {} +export interface PatchRoutesOnNavigationFunction + extends AgnosticPatchRoutesOnNavigationFunction {} /** * @category Routers @@ -145,7 +145,7 @@ export function createMemoryRouter( initialEntries?: InitialEntry[]; initialIndex?: number; unstable_dataStrategy?: DataStrategyFunction; - unstable_patchRoutesOnMiss?: PatchRoutesOnMissFunction; + unstable_patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction; } ): RemixRouter { return createRouter({ @@ -159,7 +159,7 @@ export function createMemoryRouter( routes, mapRouteProperties, unstable_dataStrategy: opts?.unstable_dataStrategy, - unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss, + unstable_patchRoutesOnNavigation: opts?.unstable_patchRoutesOnNavigation, }).initialize(); } diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index febaa3ea36..503e4384c3 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -16,7 +16,7 @@ import { UNSAFE_createRouter as createRouter, UNSAFE_deserializeErrors as deserializeErrors, UNSAFE_getSingleFetchDataStrategy as getSingleFetchDataStrategy, - UNSAFE_initFogOfWar as initFogOfWar, + UNSAFE_getPatchRoutesOnNavigationFunction as getPatchRoutesOnNavigationFunction, UNSAFE_shouldHydrateRouteLoader as shouldHydrateRouteLoader, UNSAFE_useFogOFWarDiscovery as useFogOFWarDiscovery, UNSAFE_mapRouteProperties as mapRouteProperties, @@ -186,13 +186,6 @@ function createHydratedRouter(): RemixRouter { } } - let { enabled: isFogOfWarEnabled, patchRoutesOnMiss } = initFogOfWar( - ssrInfo.manifest, - ssrInfo.routeModules, - ssrInfo.context.isSpaMode, - ssrInfo.context.basename - ); - // We don't use createBrowserRouter here because we need fine-grained control // over initialization to support synchronous `clientLoader` flows. let router = createRouter({ @@ -205,9 +198,12 @@ function createHydratedRouter(): RemixRouter { ssrInfo.manifest, ssrInfo.routeModules ), - ...(isFogOfWarEnabled - ? { unstable_patchRoutesOnMiss: patchRoutesOnMiss } - : {}), + unstable_patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( + ssrInfo.manifest, + ssrInfo.routeModules, + ssrInfo.context.isSpaMode, + ssrInfo.context.basename + ), }); ssrInfo.router = router; diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index c37a9b751a..cf236e65fa 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -64,7 +64,7 @@ import { mergeRefs, usePrefetchBehavior, } from "./ssr/components"; -import type { PatchRoutesOnMissFunction } from "../components"; +import type { PatchRoutesOnNavigationFunction } from "../components"; import { Router, mapRouteProperties } from "../components"; import type { RouteObject, NavigateOptions } from "../context"; import { @@ -123,7 +123,7 @@ interface DOMRouterOpts { future?: Partial; hydrationData?: HydrationState; unstable_dataStrategy?: DataStrategyFunction; - unstable_patchRoutesOnMiss?: PatchRoutesOnMissFunction; + unstable_patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction; window?: Window; } @@ -142,7 +142,7 @@ export function createBrowserRouter( routes, mapRouteProperties, unstable_dataStrategy: opts?.unstable_dataStrategy, - unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss, + unstable_patchRoutesOnNavigation: opts?.unstable_patchRoutesOnNavigation, window: opts?.window, }).initialize(); } @@ -162,7 +162,7 @@ export function createHashRouter( routes, mapRouteProperties, unstable_dataStrategy: opts?.unstable_dataStrategy, - unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss, + unstable_patchRoutesOnNavigation: opts?.unstable_patchRoutesOnNavigation, window: opts?.window, }).initialize(); } diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index 4dfc1ec08f..97977d3cb7 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import type { PatchRoutesOnMissFunction } from "../../components"; +import type { PatchRoutesOnNavigationFunction } from "../../components"; import type { Router as RemixRouter } from "../../router/router"; import { matchRoutes } from "../../router/utils"; import type { AssetsManifest } from "./entry"; @@ -12,23 +12,18 @@ declare global { } } -type FogOfWarInfo = { - // Currently rendered links that may need prefetching - nextPaths: Set; - // Paths we know the client can already match, so no need to perform client-side - // matching or prefetching for them. Just an optimization to avoid re-matching - // on a larger and larger route tree over time - knownGoodPaths: Set; - // Routes the server was unable to match - don't ask for them again - known404Paths: Set; -}; +// Currently rendered links that may need prefetching +const nextPaths = new Set(); + +// FIFO queue of previously discovered routes to prevent re-calling on +// subsequent navigations to the same path +const discoveredPathsMaxSize = 1000; +const discoveredPaths = new Set(); // 7.5k to come in under the ~8k limit for most browsers // https://stackoverflow.com/a/417184 const URL_LIMIT = 7680; -let fogOfWar: FogOfWarInfo | null = null; - export function isFogOfWarEnabled(isSpaMode: boolean) { return !isSpaMode; } @@ -70,44 +65,28 @@ export function getPartialManifest( }; } -export function initFogOfWar( +export function getPatchRoutesOnNavigationFunction( manifest: AssetsManifest, routeModules: RouteModules, isSpaMode: boolean, basename: string | undefined -): { - enabled: boolean; - patchRoutesOnMiss?: PatchRoutesOnMissFunction; -} { +): PatchRoutesOnNavigationFunction | undefined { if (!isFogOfWarEnabled(isSpaMode)) { - return { enabled: false }; + return undefined; } - fogOfWar = { - nextPaths: new Set(), - knownGoodPaths: new Set(), - known404Paths: new Set(), - }; - - return { - enabled: true, - patchRoutesOnMiss: async ({ path, patch }) => { - if ( - fogOfWar!.known404Paths.has(path) || - fogOfWar!.knownGoodPaths.has(path) - ) { - return; - } - await fetchAndApplyManifestPatches( - [path], - fogOfWar!, - manifest, - routeModules, - isSpaMode, - basename, - patch - ); - }, + return async ({ path, patch }) => { + if (discoveredPaths.has(path)) { + return; + } + await fetchAndApplyManifestPatches( + [path], + manifest, + routeModules, + isSpaMode, + basename, + patch + ); }; } @@ -136,16 +115,21 @@ export function useFogOFWarDiscovery( return; } let url = new URL(path, window.location.origin); - let { knownGoodPaths, known404Paths, nextPaths } = fogOfWar!; - if (knownGoodPaths.has(url.pathname) || known404Paths.has(url.pathname)) { - return; + if (!discoveredPaths.has(url.pathname)) { + nextPaths.add(url.pathname); } - nextPaths.add(url.pathname); } // Register and fetch patches for all initially-rendered links/forms async function fetchPatches() { - let lazyPaths = getFogOfWarPaths(fogOfWar!); + let lazyPaths = Array.from(nextPaths.keys()).filter((path) => { + if (discoveredPaths.has(path)) { + nextPaths.delete(path); + return false; + } + return true; + }); + if (lazyPaths.length === 0) { return; } @@ -153,7 +137,6 @@ export function useFogOFWarDiscovery( try { await fetchAndApplyManifestPatches( lazyPaths, - fogOfWar!, manifest, routeModules, isSpaMode, @@ -214,33 +197,14 @@ export function useFogOFWarDiscovery( }, [isSpaMode, manifest, routeModules, router]); } -function getFogOfWarPaths(fogOfWar: FogOfWarInfo) { - let { knownGoodPaths, known404Paths, nextPaths } = fogOfWar; - return Array.from(nextPaths.keys()).filter((path) => { - if (knownGoodPaths.has(path)) { - nextPaths.delete(path); - return false; - } - - if (known404Paths.has(path)) { - nextPaths.delete(path); - return false; - } - - return true; - }); -} - export async function fetchAndApplyManifestPatches( paths: string[], - _fogOfWar: FogOfWarInfo, manifest: AssetsManifest, routeModules: RouteModules, isSpaMode: boolean, basename: string | undefined, patchRoutes: RemixRouter["patchRoutes"] ): Promise { - let { nextPaths, knownGoodPaths, known404Paths } = _fogOfWar; let manifestPath = `${basename != null ? basename : "/"}/__manifest`.replace( /\/+/g, "/" @@ -265,16 +229,11 @@ export async function fetchAndApplyManifestPatches( throw new Error(await res.text()); } - let data = (await res.json()) as { - notFoundPaths: string[]; - patches: AssetsManifest["routes"]; - }; - - // Capture this before we apply the patches to the manifest - let knownRoutes = new Set(Object.keys(manifest.routes)); + let serverPatches = (await res.json()) as AssetsManifest["routes"]; // Patch routes we don't know about yet into the manifest - let patches: AssetsManifest["routes"] = Object.values(data.patches).reduce( + let knownRoutes = new Set(Object.keys(manifest.routes)); + let patches: AssetsManifest["routes"] = Object.values(serverPatches).reduce( (acc, route) => !knownRoutes.has(route.id) ? Object.assign(acc, { [route.id]: route }) @@ -283,11 +242,8 @@ export async function fetchAndApplyManifestPatches( ); Object.assign(manifest.routes, patches); - // Track legit 404s so we don't try to fetch them again - data.notFoundPaths.forEach((p) => known404Paths.add(p)); - - // Track matched paths so we don't have to fetch them again - paths.forEach((p) => knownGoodPaths.add(p)); + // Track discovered paths so we don't have to fetch them again + paths.forEach((p) => addToFifoQueue(p, discoveredPaths)); // Identify all parentIds for which we have new children to add and patch // in their new children @@ -305,6 +261,14 @@ export async function fetchAndApplyManifestPatches( ); } +function addToFifoQueue(path: string, queue: Set) { + if (queue.size >= discoveredPathsMaxSize) { + let first = queue.values().next().value; + queue.delete(first); + } + queue.add(path); +} + // Thanks Josh! // https://www.joshwcomeau.com/snippets/javascript/debounce/ function debounce(callback: (...args: unknown[]) => unknown, wait: number) { diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index b27babd7f8..f6adc4ab69 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -785,7 +785,7 @@ export function _renderMatches( dataRouterState.matches.length > 0 ) { // Don't bail if we're initializing with partial hydration and we have - // router matches. That means we're actively running `patchRoutesOnMiss` + // router matches. That means we're actively running `patchRoutesOnNavigation` // so we should render down the partial matches to the appropriate // `HydrateFallback`. We only do this if `parentMatches` is empty so it // only impacts the root matches for `RouterProvider` and no descendant diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 574005f345..040583c633 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -30,7 +30,7 @@ import type { Submission, SuccessResult, UIMatch, - AgnosticPatchRoutesOnMissFunction, + AgnosticPatchRoutesOnNavigationFunction, DataWithResponseInit, } from "./utils"; import { @@ -369,7 +369,7 @@ export interface RouterInit { future?: Partial; hydrationData?: HydrationState; window?: Window; - unstable_patchRoutesOnMiss?: AgnosticPatchRoutesOnMissFunction; + unstable_patchRoutesOnNavigation?: AgnosticPatchRoutesOnNavigationFunction; unstable_dataStrategy?: DataStrategyFunction; } @@ -809,7 +809,7 @@ export function createRouter(init: RouterInit): Router { let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; let basename = init.basename || "/"; let dataStrategyImpl = init.unstable_dataStrategy || defaultDataStrategy; - let patchRoutesOnMissImpl = init.unstable_patchRoutesOnMiss; + let patchRoutesOnNavigationImpl = init.unstable_patchRoutesOnNavigation; // Config driven behavior flags let future: FutureConfig = { @@ -819,6 +819,10 @@ export function createRouter(init: RouterInit): Router { let unlistenHistory: (() => void) | null = null; // Externally-provided functions to call on all state changes let subscribers = new Set(); + // FIFO queue of previously discovered routes to prevent re-calling on + // subsequent navigations to the same path + let discoveredRoutesMaxSize = 1000; + let discoveredRoutes = new Set(); // Externally-provided object to hold scroll restoration locations during routing let savedScrollPositions: Record | null = null; // Externally-provided function to get scroll restoration keys @@ -836,7 +840,7 @@ export function createRouter(init: RouterInit): Router { let initialMatches = matchRoutes(dataRoutes, init.history.location, basename); let initialErrors: RouteData | null = null; - if (initialMatches == null && !patchRoutesOnMissImpl) { + if (initialMatches == null && !patchRoutesOnNavigationImpl) { // If we do not match a user-provided-route, fall back to the root // to allow the error boundary to take over let error = getInternalRouterError(404, { @@ -847,7 +851,7 @@ export function createRouter(init: RouterInit): Router { initialErrors = { [route.id]: error }; } - // In SPA apps, if the user provided a patchRoutesOnMiss implementation and + // In SPA apps, if the user provided a patchRoutesOnNavigation implementation and // our initial match is a splat route, clear them out so we run through lazy // discovery on hydration in case there's a more accurate lazy route match. // In SSR apps (with `hydrationData`), we expect that the server will send @@ -870,7 +874,7 @@ export function createRouter(init: RouterInit): Router { initialMatches = []; // If partial hydration and fog of war is enabled, we will be running - // `patchRoutesOnMiss` during hydration so include any partial matches as + // `patchRoutesOnNavigation` during hydration so include any partial matches as // the initial matches so we can properly render `HydrateFallback`'s let fogOfWar = checkFogOfWar( null, @@ -1009,11 +1013,11 @@ export function createRouter(init: RouterInit): Router { // we don't need to update UI state if they change let blockerFunctions = new Map(); - // Map of pending patchRoutesOnMiss() promises (keyed by path/matches) so + // Map of pending patchRoutesOnNavigation() promises (keyed by path/matches) so // that we only kick them off once for a given combo let pendingPatchRoutes = new Map< string, - ReturnType + ReturnType >(); // Flag to ignore the next history update, so we can revert the URL change on @@ -3078,7 +3082,14 @@ export function createRouter(init: RouterInit): Router { routesToUse: AgnosticDataRouteObject[], pathname: string ): { active: boolean; matches: AgnosticDataRouteMatch[] | null } { - if (patchRoutesOnMissImpl) { + if (patchRoutesOnNavigationImpl) { + // Don't bother re-calling patchRouteOnMiss for a path we've already + // processed. the last execution would have patched the route tree + // accordingly so `matches` here are already accurate. + if (discoveredRoutes.has(pathname)) { + return { active: false, matches }; + } + if (!matches) { let fogMatches = matchRoutesImpl( routesToUse, @@ -3089,14 +3100,10 @@ export function createRouter(init: RouterInit): Router { return { active: true, matches: fogMatches || [] }; } else { - let leafRoute = matches[matches.length - 1].route; - if ( - leafRoute.path && - (leafRoute.path === "*" || leafRoute.path.endsWith("/*")) - ) { - // If we matched a splat, it might only be because we haven't yet fetched - // the children that would match with a higher score, so let's fetch - // around and find out + if (Object.keys(matches[0].params).length > 0) { + // If we matched a dynamic param or a splat, it might only be because + // we haven't yet discovered other routes that would match with a + // higher score. Call patchRoutesOnNavigation just to be sure let partialMatches = matchRoutesImpl( routesToUse, pathname, @@ -3132,16 +3139,12 @@ export function createRouter(init: RouterInit): Router { signal: AbortSignal ): Promise { let partialMatches: AgnosticDataRouteMatch[] | null = matches; - let route = - partialMatches.length > 0 - ? partialMatches[partialMatches.length - 1].route - : null; while (true) { let isNonHMR = inFlightDataRoutes == null; let routesToUse = inFlightDataRoutes || dataRoutes; try { await loadLazyRouteChildren( - patchRoutesOnMissImpl!, + patchRoutesOnNavigationImpl!, pathname, partialMatches, routesToUse, @@ -3169,26 +3172,9 @@ export function createRouter(init: RouterInit): Router { } let newMatches = matchRoutes(routesToUse, pathname, basename); - let matchedSplat = false; if (newMatches) { - let leafRoute = newMatches[newMatches.length - 1].route; - - if (leafRoute.index) { - // If we found an index route, we can stop - return { type: "success", matches: newMatches }; - } - - if (leafRoute.path && leafRoute.path.length > 0) { - if (leafRoute.path === "*") { - // If we found a splat route, we can't be sure there's not a - // higher-scoring route down some partial matches trail so we need - // to check that out - matchedSplat = true; - } else { - // If we found a non-splat route, we can stop - return { type: "success", matches: newMatches }; - } - } + addToFifoQueue(pathname, discoveredRoutes); + return { type: "success", matches: newMatches }; } let newPartialMatches = matchRoutesImpl( @@ -3198,26 +3184,30 @@ export function createRouter(init: RouterInit): Router { true ); - // If we are no longer partially matching anything, this was either a - // legit splat match above, or it's a 404. Also avoid loops if the - // second pass results in the same partial matches + // Avoid loops if the second pass results in the same partial matches if ( !newPartialMatches || - partialMatches.map((m) => m.route.id).join("-") === - newPartialMatches.map((m) => m.route.id).join("-") + (partialMatches.length === newPartialMatches.length && + partialMatches.every( + (m, i) => m.route.id === newPartialMatches![i].route.id + )) ) { - return { type: "success", matches: matchedSplat ? newMatches : null }; + addToFifoQueue(pathname, discoveredRoutes); + return { type: "success", matches: null }; } partialMatches = newPartialMatches; - route = partialMatches[partialMatches.length - 1].route; - if (route.path === "*") { - // The splat is still our most accurate partial, so run with it - return { type: "success", matches: partialMatches }; - } } } + function addToFifoQueue(path: string, queue: Set) { + if (queue.size >= discoveredRoutesMaxSize) { + let first = queue.values().next().value; + queue.delete(first); + } + queue.add(path); + } + function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) { manifest = {}; inFlightDataRoutes = convertRoutesToDataRoutes( @@ -4384,24 +4374,24 @@ function shouldRevalidateLoader( } /** - * Idempotent utility to execute patchRoutesOnMiss() to lazily load route + * Idempotent utility to execute patchRoutesOnNavigation() to lazily load route * definitions and update the routes/routeManifest */ async function loadLazyRouteChildren( - patchRoutesOnMissImpl: AgnosticPatchRoutesOnMissFunction, + patchRoutesOnNavigationImpl: AgnosticPatchRoutesOnNavigationFunction, path: string, matches: AgnosticDataRouteMatch[], routes: AgnosticDataRouteObject[], manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, - pendingRouteChildren: Map>, + pendingRouteChildren: Map>, signal: AbortSignal ) { let key = [path, ...matches.map((m) => m.route.id)].join("-"); try { let pending = pendingRouteChildren.get(key); if (!pending) { - pending = patchRoutesOnMissImpl({ + pending = patchRoutesOnNavigationImpl({ path, matches, patch: (routeId, children) => { @@ -5197,7 +5187,7 @@ function getInternalRouterError( statusText = "Bad Request"; if (type === "route-discovery") { errorMessage = - `Unable to match URL "${pathname}" - the \`unstable_patchRoutesOnMiss()\` ` + + `Unable to match URL "${pathname}" - the \`unstable_patchRoutesOnNavigation()\` ` + `function threw the following error:\n${message}`; } else if (method && pathname && routeId) { errorMessage = diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index fe366043d4..f08019ef87 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -222,7 +222,7 @@ export interface DataStrategyFunction { (args: DataStrategyFunctionArgs): Promise; } -export interface AgnosticPatchRoutesOnMissFunction< +export interface AgnosticPatchRoutesOnNavigationFunction< M extends AgnosticRouteMatch = AgnosticRouteMatch > { (opts: { diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 3213e83fc5..96bddbe5b4 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -163,7 +163,11 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( if (isRedirectResponse(response)) { let result: SingleFetchResult | SingleFetchResults = - getSingleFetchRedirect(response.status, response.headers); + getSingleFetchRedirect( + response.status, + response.headers, + _build.basename + ); if (request.method === "GET") { result = { @@ -235,10 +239,7 @@ async function handleManifestRequest( routes: ServerRoute[], url: URL ) { - let data: { - patches: Record; - notFoundPaths: string[]; - } = { patches: {}, notFoundPaths: [] }; + let patches: Record = {}; if (url.searchParams.has("p")) { for (let path of url.searchParams.getAll("p")) { @@ -246,14 +247,12 @@ async function handleManifestRequest( if (matches) { for (let match of matches) { let routeId = match.route.id; - data.patches[routeId] = build.assets.routes[routeId]; + patches[routeId] = build.assets.routes[routeId]; } - } else { - data.notFoundPaths.push(path); } } - return json(data, { + return json(patches, { headers: { "Cache-Control": "public, max-age=31536000, immutable", }, diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 0a7f085c30..e887563b5b 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -9,13 +9,14 @@ import { isRouteErrorResponse, ErrorResponseImpl, data as routerData, + stripBasename, } from "../router/utils"; -import { - type SingleFetchRedirectResult, - type SingleFetchResult, - type SingleFetchResults, - SingleFetchRedirectSymbol, +import type { + SingleFetchRedirectResult, + SingleFetchResult, + SingleFetchResults, } from "../dom/ssr/single-fetch"; +import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; import type { AppLoadContext } from "./data"; import { sanitizeError, sanitizeErrors } from "./errors"; import { ServerMode } from "./mode"; @@ -97,7 +98,11 @@ export async function singleFetchAction( // let non-Response return values through if (isResponse(result)) { return { - result: getSingleFetchRedirect(result.status, result.headers), + result: getSingleFetchRedirect( + result.status, + result.headers, + build.basename + ), headers: result.headers, status: SINGLE_FETCH_REDIRECT_STATUS, }; @@ -108,7 +113,11 @@ export async function singleFetchAction( if (isRedirectStatusCode(context.statusCode) && headers.has("Location")) { return { - result: getSingleFetchRedirect(context.statusCode, headers), + result: getSingleFetchRedirect( + context.statusCode, + headers, + build.basename + ), headers, status: SINGLE_FETCH_REDIRECT_STATUS, }; @@ -178,7 +187,8 @@ export async function singleFetchLoaders( result: { [SingleFetchRedirectSymbol]: getSingleFetchRedirect( result.status, - result.headers + result.headers, + build.basename ), }, headers: result.headers, @@ -194,7 +204,8 @@ export async function singleFetchLoaders( result: { [SingleFetchRedirectSymbol]: getSingleFetchRedirect( context.statusCode, - headers + headers, + build.basename ), }, headers, @@ -250,10 +261,17 @@ export async function singleFetchLoaders( export function getSingleFetchRedirect( status: number, - headers: Headers + headers: Headers, + basename: string | undefined ): SingleFetchRedirectResult { + let redirect = headers.get("Location")!; + + if (basename) { + redirect = stripBasename(redirect, basename) || redirect; + } + return { - redirect: headers.get("Location")!, + redirect, status, revalidate: // Technically X-Remix-Revalidate isn't needed here - that was an implementation diff --git a/packages/react-router/package.json b/packages/react-router/package.json index aa6889a069..9ff9c2ccde 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -42,7 +42,7 @@ "react-router": "workspace:*", "set-cookie-parser": "^2.6.0", "source-map": "^0.7.3", - "turbo-stream": "^2.2.0" + "turbo-stream": "2.3.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1561711928..ff82ae8b36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,8 +548,8 @@ importers: specifier: ^0.7.3 version: 0.7.4 turbo-stream: - specifier: ^2.2.0 - version: 2.2.0 + specifier: 2.3.0 + version: 2.3.0 devDependencies: '@types/set-cookie-parser': specifier: ^2.4.1 @@ -13591,8 +13591,8 @@ packages: yargs: 17.7.2 dev: false - /turbo-stream@2.2.0: - resolution: {integrity: sha512-FKFg7A0To1VU4CH9YmSMON5QphK0BXjSoiC7D9yMh+mEEbXLUP9qJ4hEt1qcjKtzncs1OpcnjZO8NgrlVbZH+g==} + /turbo-stream@2.3.0: + resolution: {integrity: sha512-PhEr9mdexoVv+rJkQ3c8TjrN3DUghX37GNJkSMksoPR4KrXIPnM2MnqRt07sViIqX9IdlhrgtTSyjoVOASq6cg==} dev: false /type-check@0.4.0: