diff --git a/.changeset/catch-unwrap-error.md b/.changeset/catch-unwrap-error.md deleted file mode 100644 index 1a89d4b35c..0000000000 --- a/.changeset/catch-unwrap-error.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@remix-run/router": patch ---- - -Catch and bubble errors thrown when trying to unwrap responses from `loader`/`action` functions diff --git a/.changeset/handle-falsy-errors.md b/.changeset/handle-falsy-errors.md deleted file mode 100644 index 59a5b8b6e5..0000000000 --- a/.changeset/handle-falsy-errors.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -Properly handle falsy error values in ErrorBoundary's diff --git a/.changeset/partial-hydration-data.md b/.changeset/partial-hydration-data.md deleted file mode 100644 index b5c7157022..0000000000 --- a/.changeset/partial-hydration-data.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@remix-run/router": minor ---- - -Added a new `future.v7_partialHydration` future flag that enables partial hydration of a data router when Server-Side Rendering. This allows you to provide `hydrationData.loaderData` that has values for _some_ initially matched route loaders, but not all. When this flag is enabled, the router will call `loader` functions for routes that do not have hydration loader data during `router.initialize()`, and it will render down to the deepest provided `HydrateFallback` (up to the first route without hydration data) while it executes the unhydrated routes. - -For example, the following router has a `root` and `index` route, but only provided `hydrationData.loaderData` for the `root` route. Because the `index` route has a `loader`, we need to run that during initialization. With `future.v7_partialHydration` specified, `` will render the `RootComponent` (because it has data) and then the `IndexFallback` (since it does not have data). Once `indexLoader` finishes, application will update and display `IndexComponent`. - -```jsx -let router = createBrowserRouter( - [ - { - id: "root", - path: "/", - loader: rootLoader, - Component: RootComponent, - Fallback: RootFallback, - children: [ - { - id: "index", - index: true, - loader: indexLoader, - Component: IndexComponent, - HydrateFallback: IndexFallback, - }, - ], - }, - ], - { - future: { - v7_partialHydration: true, - }, - hydrationData: { - loaderData: { - root: { message: "Hydrated from Root!" }, - }, - }, - } -); -``` - -If the above example did not have an `IndexFallback`, then `RouterProvider` would instead render the `RootFallback` while it executed the `indexLoader`. - -**Note:** When `future.v7_partialHydration` is provided, the `` prop is ignored since you can move it to a `Fallback` on your top-most route. The `fallbackElement` prop will be removed in React Router v7 when `v7_partialHydration` behavior becomes the standard behavior. diff --git a/.changeset/pretty-dolphins-relax.md b/.changeset/pretty-dolphins-relax.md deleted file mode 100644 index bd30b1c8ca..0000000000 --- a/.changeset/pretty-dolphins-relax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@remix-run/router": patch ---- - -Fix `relative="path"` issue when rendering `Link`/`NavLink` outside of matched routes diff --git a/.changeset/relative-splat-path.md b/.changeset/relative-splat-path.md deleted file mode 100644 index bcaa9c8d4a..0000000000 --- a/.changeset/relative-splat-path.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -"react-router-dom-v5-compat": minor -"react-router-native": minor -"react-router-dom": minor -"react-router": minor -"@remix-run/router": minor ---- - -Add a new `future.v7_relativeSplatPath` flag to implenent a breaking bug fix to relative routing when inside a splat route. - -This fix was originally added in [#10983](https://github.com/remix-run/react-router/issues/10983) and was later reverted in [#11078](https://github.com/remix-run/react-router/issues/110788) because it was determined that a large number of existing applications were relying on the buggy behavior (see [#11052](https://github.com/remix-run/react-router/issues/11052)) - -**The Bug** -The buggy behavior is that without this flag, the default behavior when resolving relative paths is to _ignore_ any splat (`*`) portion of the current route path. - -**The Background** -This decision was originally made thinking that it would make the concept of nested different sections of your apps in `` easier if relative routing would _replace_ the current splat: - -```jsx - - - } /> - } /> - - -``` - -Any paths like `/dashboard`, `/dashboard/team`, `/dashboard/projects` will match the `Dashboard` route. The dashboard component itself can then render nested ``: - -```jsx -function Dashboard() { - return ( -
-

Dashboard

- - - - } /> - } /> - } /> - -
- ); -} -``` - -Now, all links and route paths are relative to the router above them. This makes code splitting and compartmentalizing your app really easy. You could render the `Dashboard` as its own independent app, or embed it into your large app without making any changes to it. - -**The Problem** - -The problem is that this concept of ignoring part of a pth breaks a lot of other assumptions in React Router - namely that `"."` always means the current location pathname for that route. When we ignore the splat portion, we start getting invalid paths when using `"."`: - -```jsx -// If we are on URL /dashboard/team, and we want to link to /dashboard/team: -function DashboardTeam() { - // ❌ This is broken and results in - return A broken link to the Current URL; - - // ✅ This is fixed but super unintuitive since we're already at /dashboard/team! - return A broken link to the Current URL; -} -``` - -We've also introduced an issue that we can no longer move our `DashboardTeam` component around our route hierarchy easily - since it behaves differently if we're underneath a non-splat route, such as `/dashboard/:widget`. Now, our `"."` links will, properly point to ourself _inclusive of the dynamic param value_ so behavior will break from it's corresponding usage in a `/dashboard/*` route. - -Even worse, consider a nested splat route configuration: - -```jsx - - - - } /> - - - -``` - -Now, a `` and a `` inside the `Dashboard` component go to the same place! That is definitely not correct! - -Another common issue arose in Data Routers (and Remix) where any `
` should post to it's own route `action` if you the user doesn't specify a form action: - -```jsx -let router = createBrowserRouter({ - path: "/dashboard", - children: [ - { - path: "*", - action: dashboardAction, - Component() { - // ❌ This form is broken! It throws a 405 error when it submits because - // it tries to submit to /dashboard (without the splat value) and the parent - // `/dashboard` route doesn't have an action - return ...
; - }, - }, - ], -}); -``` - -This is just a compounded issue from the above because the default location for a `Form` to submit to is itself (`"."`) - and if we ignore the splat portion, that now resolves to the parent route. - -**The Solution** -If you are leveraging this behavior, it's recommended to enable the future flag, move your splat to it's own route, and leverage `../` for any links to "sibling" pages: - -```jsx - - - - } /> - - - - -function Dashboard() { - return ( -
-

Dashboard

- - - - } /> - } /> - } /> - -
- ); -} -``` - -This way, `.` means "the full current pathname for my route" in all cases (including static, dynamic, and splat routes) and `..` always means "my parents pathname". diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e80ef14f..4aa0546c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,122 +13,128 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) - - [v6.20.1](#v6201) - - [Patch Changes](#patch-changes) - - [v6.20.0](#v6200) + - [v6.21.0](#v6210) + - [What's Changed](#whats-changed) + - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) + - [Partial Hydration](#partial-hydration) - [Minor Changes](#minor-changes) + - [Patch Changes](#patch-changes) + - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-1) - - [v6.19.0](#v6190) - - [What's Changed](#whats-changed) - - [`unstable_flushSync` API](#unstable_flushsync-api) + - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-1) - [Patch Changes](#patch-changes-2) - - [v6.18.0](#v6180) + - [v6.19.0](#v6190) - [What's Changed](#whats-changed-1) - - [New Fetcher APIs](#new-fetcher-apis) - - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) + - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-2) - [Patch Changes](#patch-changes-3) - - [v6.17.0](#v6170) + - [v6.18.0](#v6180) - [What's Changed](#whats-changed-2) - - [View Transitions 🚀](#view-transitions-) + - [New Fetcher APIs](#new-fetcher-apis) + - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-3) - [Patch Changes](#patch-changes-4) - - [v6.16.0](#v6160) + - [v6.17.0](#v6170) + - [What's Changed](#whats-changed-3) + - [View Transitions 🚀](#view-transitions-) - [Minor Changes](#minor-changes-4) - [Patch Changes](#patch-changes-5) - - [v6.15.0](#v6150) + - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-5) - [Patch Changes](#patch-changes-6) - - [v6.14.2](#v6142) + - [v6.15.0](#v6150) + - [Minor Changes](#minor-changes-6) - [Patch Changes](#patch-changes-7) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-8) - - [v6.14.0](#v6140) - - [What's Changed](#whats-changed-3) - - [JSON/Text Submissions](#jsontext-submissions) - - [Minor Changes](#minor-changes-6) + - [v6.14.1](#v6141) - [Patch Changes](#patch-changes-9) - - [v6.13.0](#v6130) + - [v6.14.0](#v6140) - [What's Changed](#whats-changed-4) + - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-7) - [Patch Changes](#patch-changes-10) - - [v6.12.1](#v6121) - - [Patch Changes](#patch-changes-11) - - [v6.12.0](#v6120) + - [v6.13.0](#v6130) - [What's Changed](#whats-changed-5) - - [`React.startTransition` support](#reactstarttransition-support) - [Minor Changes](#minor-changes-8) + - [Patch Changes](#patch-changes-11) + - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-12) - - [v6.11.2](#v6112) + - [v6.12.0](#v6120) + - [What's Changed](#whats-changed-6) + - [`React.startTransition` support](#reactstarttransition-support) + - [Minor Changes](#minor-changes-9) - [Patch Changes](#patch-changes-13) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-14) - - [v6.11.0](#v6110) - - [Minor Changes](#minor-changes-9) + - [v6.11.1](#v6111) - [Patch Changes](#patch-changes-15) - - [v6.10.0](#v6100) - - [What's Changed](#whats-changed-6) + - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-10) - [Patch Changes](#patch-changes-16) - - [v6.9.0](#v690) + - [v6.10.0](#v6100) - [What's Changed](#whats-changed-7) - - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-11) - [Patch Changes](#patch-changes-17) - - [v6.8.2](#v682) + - [v6.9.0](#v690) + - [What's Changed](#whats-changed-8) + - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) + - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) + - [Minor Changes](#minor-changes-12) - [Patch Changes](#patch-changes-18) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-19) - - [v6.8.0](#v680) - - [Minor Changes](#minor-changes-12) + - [v6.8.1](#v681) - [Patch Changes](#patch-changes-20) - - [v6.7.0](#v670) + - [v6.8.0](#v680) - [Minor Changes](#minor-changes-13) - [Patch Changes](#patch-changes-21) - - [v6.6.2](#v662) + - [v6.7.0](#v670) + - [Minor Changes](#minor-changes-14) - [Patch Changes](#patch-changes-22) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-23) - - [v6.6.0](#v660) - - [What's Changed](#whats-changed-8) - - [Minor Changes](#minor-changes-14) + - [v6.6.1](#v661) - [Patch Changes](#patch-changes-24) - - [v6.5.0](#v650) + - [v6.6.0](#v660) - [What's Changed](#whats-changed-9) - [Minor Changes](#minor-changes-15) - [Patch Changes](#patch-changes-25) - - [v6.4.5](#v645) + - [v6.5.0](#v650) + - [What's Changed](#whats-changed-10) + - [Minor Changes](#minor-changes-16) - [Patch Changes](#patch-changes-26) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-27) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-28) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-29) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-30) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-31) - [v6.4.0](#v640) - - [What's Changed](#whats-changed-10) + - [What's Changed](#whats-changed-11) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-31) + - [Patch Changes](#patch-changes-32) - [v6.3.0](#v630) - - [Minor Changes](#minor-changes-16) + - [Minor Changes](#minor-changes-17) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-32) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-33) - - [v6.2.0](#v620) - - [Minor Changes](#minor-changes-17) + - [v6.2.1](#v621) - [Patch Changes](#patch-changes-34) - - [v6.1.1](#v611) - - [Patch Changes](#patch-changes-35) - - [v6.1.0](#v610) + - [v6.2.0](#v620) - [Minor Changes](#minor-changes-18) + - [Patch Changes](#patch-changes-35) + - [v6.1.1](#v611) - [Patch Changes](#patch-changes-36) - - [v6.0.1](#v601) + - [v6.1.0](#v610) + - [Minor Changes](#minor-changes-19) - [Patch Changes](#patch-changes-37) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-38) - [v6.0.0](#v600) @@ -152,6 +158,35 @@ To add a new release, copy from this template: --> +## v6.21.0 + +### What's Changed + +#### `future.v7_relativeSplatPath` + +We fixed a splat route path-resolution bug in `6.19.0`, but later determined a large number of applications were relying on the buggy behavior, so we reverted the fix in `6.20.1` (see [#10983](https://github.com/remix-run/react-router/issues/10983), [#11052](https://github.com/remix-run/react-router/issues/11052), [#11078](https://github.com/remix-run/react-router/issues/11078)). + +The buggy behavior is that the default behavior when resolving relative paths inside a splat route would _ignore_ any splat (`*`) portion of the current route path. When the future flag is enabled, splat portions are included in relative path logic within splat routes. + +For more information, please refer to the [`useResolvedPath` docs](https://reactrouter.com/hooks/use-resolved-path#splat-paths) and/or the [detailed changelog entry](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md#6210). + +#### Partial Hydration + +We added a new `future.v7_partialHydration` future flag for the `@remix-run/router` that enables partial hydration of a data router when Server-Side Rendering. This allows you to provide `hydrationData.loaderData` that has values for _some_ initially matched route loaders, but not all. When this flag is enabled, the router will call `loader` functions for routes that do not have hydration loader data during `router.initialize()`, and it will render down to the deepest provided `HydrateFallback` (up to the first route without hydration data) while it executes the unhydrated routes. ([#11033](https://github.com/remix-run/react-router/pull/11033)) + +### Minor Changes + +- Add a new `future.v7_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. ([#11087](https://github.com/remix-run/react-router/pull/11087)) +- Add a new `future.v7_partialHydration` future flag that enables partial hydration of a data router when Server-Side Rendering ([#11033](https://github.com/remix-run/react-router/pull/11033)) + +### Patch Changes + +- Properly handle falsy error values in `ErrorBoundary`'s ([#11071](https://github.com/remix-run/react-router/pull/11071)) +- Catch and bubble errors thrown when trying to unwrap responses from `loader`/`action` functions ([#11061](https://github.com/remix-run/react-router/pull/11061)) +- Fix `relative="path"` issue when rendering `Link`/`NavLink` outside of matched routes ([#11062](https://github.com/remix-run/react-router/pull/11062)) + +**Full Changelog**: [`v6.20.1...v6.21.0`](https://github.com/remix-run/react-router/compare/react-router@6.20.1...react-router@6.21.0) + ## v6.20.1 ### Patch Changes diff --git a/docs/hooks/use-resolved-path.md b/docs/hooks/use-resolved-path.md index 9f9cbeea86..efc70f8e71 100644 --- a/docs/hooks/use-resolved-path.md +++ b/docs/hooks/use-resolved-path.md @@ -71,7 +71,7 @@ And then it gets worse if you define the splat route as a child: ### Behavior with the flag -When you enable the flag, this "bug" is fixed so that path resolution is consistent across all route types, `useResolvedPath(".")` always resolves to the current pathname for the contextual route. This includes any dynamic param or splat param values. +When you enable the flag, this "bug" is fixed so that path resolution is consistent across all route types, and `useResolvedPath(".")` always resolves to the current pathname for the contextual route. This includes any dynamic param or splat param values. If you want to navigate between "sibling" routes within a splat route, it is suggested you move your splat route to it's own child (`} />`) and use `useResolvedPath("../teams")` and `useResolvedPath("../projects")` parent-relative paths to navigate to sibling routes. diff --git a/package.json b/package.json index 53338bc823..a87b49acfe 100644 --- a/package.json +++ b/package.json @@ -110,19 +110,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "50.0 kB" + "none": "50.4 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "14.6 kB" + "none": "14.7 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "17.0 kB" + "none": "17.1 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "16.8 kB" + "none": "16.9 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "23.0 kB" + "none": "23.1 kB" } } } diff --git a/packages/react-router-dom-v5-compat/CHANGELOG.md b/packages/react-router-dom-v5-compat/CHANGELOG.md index b3094d7582..8c1fdd5ef3 100644 --- a/packages/react-router-dom-v5-compat/CHANGELOG.md +++ b/packages/react-router-dom-v5-compat/CHANGELOG.md @@ -1,5 +1,146 @@ # `react-router-dom-v5-compat` +## 6.21.0 + +### Minor Changes + +- Add a new `future.v7_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. ([#11087](https://github.com/remix-run/react-router/pull/11087)) + + This fix was originally added in [#10983](https://github.com/remix-run/react-router/issues/10983) and was later reverted in [#11078](https://github.com/remix-run/react-router/pull/11078) because it was determined that a large number of existing applications were relying on the buggy behavior (see [#11052](https://github.com/remix-run/react-router/issues/11052)) + + **The Bug** + The buggy behavior is that without this flag, the default behavior when resolving relative paths is to _ignore_ any splat (`*`) portion of the current route path. + + **The Background** + This decision was originally made thinking that it would make the concept of nested different sections of your apps in `` easier if relative routing would _replace_ the current splat: + + ```jsx + + + } /> + } /> + + + ``` + + Any paths like `/dashboard`, `/dashboard/team`, `/dashboard/projects` will match the `Dashboard` route. The dashboard component itself can then render nested ``: + + ```jsx + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + Now, all links and route paths are relative to the router above them. This makes code splitting and compartmentalizing your app really easy. You could render the `Dashboard` as its own independent app, or embed it into your large app without making any changes to it. + + **The Problem** + + The problem is that this concept of ignoring part of a path breaks a lot of other assumptions in React Router - namely that `"."` always means the current location pathname for that route. When we ignore the splat portion, we start getting invalid paths when using `"."`: + + ```jsx + // If we are on URL /dashboard/team, and we want to link to /dashboard/team: + function DashboardTeam() { + // ❌ This is broken and results in
+ return A broken link to the Current URL; + + // ✅ This is fixed but super unintuitive since we're already at /dashboard/team! + return A broken link to the Current URL; + } + ``` + + We've also introduced an issue that we can no longer move our `DashboardTeam` component around our route hierarchy easily - since it behaves differently if we're underneath a non-splat route, such as `/dashboard/:widget`. Now, our `"."` links will, properly point to ourself _inclusive of the dynamic param value_ so behavior will break from it's corresponding usage in a `/dashboard/*` route. + + Even worse, consider a nested splat route configuration: + + ```jsx + + + + } /> + + + + ``` + + Now, a `` and a `` inside the `Dashboard` component go to the same place! That is definitely not correct! + + Another common issue arose in Data Routers (and Remix) where any `
` should post to it's own route `action` if you the user doesn't specify a form action: + + ```jsx + let router = createBrowserRouter({ + path: "/dashboard", + children: [ + { + path: "*", + action: dashboardAction, + Component() { + // ❌ This form is broken! It throws a 405 error when it submits because + // it tries to submit to /dashboard (without the splat value) and the parent + // `/dashboard` route doesn't have an action + return ...
; + }, + }, + ], + }); + ``` + + This is just a compounded issue from the above because the default location for a `Form` to submit to is itself (`"."`) - and if we ignore the splat portion, that now resolves to the parent route. + + **The Solution** + If you are leveraging this behavior, it's recommended to enable the future flag, move your splat to it's own route, and leverage `../` for any links to "sibling" pages: + + ```jsx + + + + } /> + + + + + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + This way, `.` means "the full current pathname for my route" in all cases (including static, dynamic, and splat routes) and `..` always means "my parents pathname". + +### Patch Changes + +- Updated dependencies: + - `react-router-dom@6.21.0` + - `react-router@6.21.0` + ## 6.20.1 ### Patch Changes diff --git a/packages/react-router-dom-v5-compat/package.json b/packages/react-router-dom-v5-compat/package.json index 62bb9c7539..5c99ce9aec 100644 --- a/packages/react-router-dom-v5-compat/package.json +++ b/packages/react-router-dom-v5-compat/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom-v5-compat", - "version": "6.20.1", + "version": "6.21.0", "description": "Migration path to React Router v6 from v4/5", "keywords": [ "react", @@ -24,7 +24,7 @@ "types": "./dist/index.d.ts", "dependencies": { "history": "^5.3.0", - "react-router": "6.20.1" + "react-router": "6.21.0" }, "peerDependencies": { "react": ">=16.8", diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 7ca4a83865..1095a506c5 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,146 @@ # `react-router-dom` +## 6.21.0 + +### Minor Changes + +- Add a new `future.v7_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. ([#11087](https://github.com/remix-run/react-router/pull/11087)) + + This fix was originally added in [#10983](https://github.com/remix-run/react-router/issues/10983) and was later reverted in [#11078](https://github.com/remix-run/react-router/pull/11078) because it was determined that a large number of existing applications were relying on the buggy behavior (see [#11052](https://github.com/remix-run/react-router/issues/11052)) + + **The Bug** + The buggy behavior is that without this flag, the default behavior when resolving relative paths is to _ignore_ any splat (`*`) portion of the current route path. + + **The Background** + This decision was originally made thinking that it would make the concept of nested different sections of your apps in `` easier if relative routing would _replace_ the current splat: + + ```jsx + + + } /> + } /> + + + ``` + + Any paths like `/dashboard`, `/dashboard/team`, `/dashboard/projects` will match the `Dashboard` route. The dashboard component itself can then render nested ``: + + ```jsx + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + Now, all links and route paths are relative to the router above them. This makes code splitting and compartmentalizing your app really easy. You could render the `Dashboard` as its own independent app, or embed it into your large app without making any changes to it. + + **The Problem** + + The problem is that this concept of ignoring part of a path breaks a lot of other assumptions in React Router - namely that `"."` always means the current location pathname for that route. When we ignore the splat portion, we start getting invalid paths when using `"."`: + + ```jsx + // If we are on URL /dashboard/team, and we want to link to /dashboard/team: + function DashboardTeam() { + // ❌ This is broken and results in
+ return A broken link to the Current URL; + + // ✅ This is fixed but super unintuitive since we're already at /dashboard/team! + return A broken link to the Current URL; + } + ``` + + We've also introduced an issue that we can no longer move our `DashboardTeam` component around our route hierarchy easily - since it behaves differently if we're underneath a non-splat route, such as `/dashboard/:widget`. Now, our `"."` links will, properly point to ourself _inclusive of the dynamic param value_ so behavior will break from it's corresponding usage in a `/dashboard/*` route. + + Even worse, consider a nested splat route configuration: + + ```jsx + + + + } /> + + + + ``` + + Now, a `` and a `` inside the `Dashboard` component go to the same place! That is definitely not correct! + + Another common issue arose in Data Routers (and Remix) where any `
` should post to it's own route `action` if you the user doesn't specify a form action: + + ```jsx + let router = createBrowserRouter({ + path: "/dashboard", + children: [ + { + path: "*", + action: dashboardAction, + Component() { + // ❌ This form is broken! It throws a 405 error when it submits because + // it tries to submit to /dashboard (without the splat value) and the parent + // `/dashboard` route doesn't have an action + return ...
; + }, + }, + ], + }); + ``` + + This is just a compounded issue from the above because the default location for a `Form` to submit to is itself (`"."`) - and if we ignore the splat portion, that now resolves to the parent route. + + **The Solution** + If you are leveraging this behavior, it's recommended to enable the future flag, move your splat to it's own route, and leverage `../` for any links to "sibling" pages: + + ```jsx + + + + } /> + + + + + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + This way, `.` means "the full current pathname for my route" in all cases (including static, dynamic, and splat routes) and `..` always means "my parents pathname". + +### Patch Changes + +- Updated dependencies: + - `@remix-run/router@1.14.0` + - `react-router@6.21.0` + ## 6.20.1 ### Patch Changes diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 06c7f4e5ec..7034c292a6 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -7464,419 +7464,6 @@ function testDomRouter( expect(spy).toHaveBeenCalledTimes(2); }); }); - - // TODO: Probably want these running against RouterProvider in react-router too? - // Look into extracting the setState stuff and sharing the subscriber, - // layout effect, navigator, render stuff - describe("partial hydration", () => { - it("does not handle partial hydration by default", async () => { - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => "INDEX", - HydrateFallback: () =>

Should not see me

, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - undefined -

-
-
" - `); - }); - - it("supports partial hydration w/leaf fallback", async () => { - let dfd = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd.promise, - HydrateFallback: () =>

Index Loading...

, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index Loading... -

-
-
" - `); - - dfd.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("supports partial hydration w/root fallback", async () => { - let dfd = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - HydrateFallback: () =>

Root Loading...

, - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd.promise, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-

- Root Loading... -

-
" - `); - - dfd.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("supports partial hydration w/no fallback", async () => { - let dfd = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd.promise, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(`"
"`); - - dfd.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("deprecates fallbackElement", async () => { - let dfd1 = createDeferred(); - let dfd2 = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => dfd1.promise, - HydrateFallback: () =>

Root Loading...

, - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd2.promise, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render( - fallbackElement...

} - /> - ); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-

- Root Loading... -

-
" - `); - - expect(consoleWarn).toHaveBeenCalledWith( - "`` is deprecated when using `v7_partialHydration`" - ); - - dfd1.resolve("ROOT DATA"); - dfd2.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("does not re-run loaders that don't have loader data due to errors", async () => { - let spy = jest.fn(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: spy, - HydrateFallback: () =>

Index Loading...

, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - ErrorBoundary() { - let error = useRouteError() as string; - return

{error}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - errors: { - index: "INDEX ERROR", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- INDEX ERROR -

-
-
" - `); - - expect(spy).not.toHaveBeenCalled(); - }); - }); }); } diff --git a/packages/react-router-dom/__tests__/partial-hydration-test.tsx b/packages/react-router-dom/__tests__/partial-hydration-test.tsx new file mode 100644 index 0000000000..49bfe12f8f --- /dev/null +++ b/packages/react-router-dom/__tests__/partial-hydration-test.tsx @@ -0,0 +1,524 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import * as React from "react"; +import type { LoaderFunction } from "react-router"; +import { RouterProvider as ReactRouter_RouterPRovider } from "react-router"; +import { + Outlet, + RouterProvider as ReactRouterDom_RouterProvider, + createBrowserRouter, + createHashRouter, + createMemoryRouter, + useLoaderData, + useRouteError, +} from "react-router-dom"; + +import getHtml from "../../react-router/__tests__/utils/getHtml"; +import { createDeferred } from "../../router/__tests__/utils/utils"; + +let didAssertMissingHydrateFallback = false; + +describe("v7_partialHydration", () => { + describe("createBrowserRouter", () => { + testPartialHydration(createBrowserRouter, ReactRouterDom_RouterProvider); + }); + + describe("createHashRouter", () => { + testPartialHydration(createHashRouter, ReactRouterDom_RouterProvider); + }); + + describe("createMemoryRouter", () => { + testPartialHydration(createMemoryRouter, ReactRouter_RouterPRovider); + }); +}); + +function testPartialHydration( + createTestRouter: + | typeof createBrowserRouter + | typeof createHashRouter + | typeof createMemoryRouter, + RouterProvider: + | typeof ReactRouterDom_RouterProvider + | typeof ReactRouter_RouterPRovider +) { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it("does not handle partial hydration by default", async () => { + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => "INDEX", + HydrateFallback: () =>

Should not see me

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - undefined +

+
" + `); + }); + + it("supports partial hydration w/leaf fallback", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd.promise, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index Loading... +

+
" + `); + + dfd.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("supports partial hydration w/root fallback", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + HydrateFallback: () =>

Root Loading...

, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd.promise, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root Loading... +

+
" + `); + + dfd.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("supports partial hydration w/no fallback", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd.promise, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(`"
"`); + + // We can't assert this in all 3 test executions because we use `warningOnce` + // internally to avoid logging on every render + if (!didAssertMissingHydrateFallback) { + didAssertMissingHydrateFallback = true; + // eslint-disable-next-line jest/no-conditional-expect + expect(consoleWarn).toHaveBeenCalledWith( + "No `HydrateFallback` element provided to render during initial hydration" + ); + } + + dfd.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("deprecates fallbackElement", async () => { + let dfd1 = createDeferred(); + let dfd2 = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => dfd1.promise, + HydrateFallback: () =>

Root Loading...

, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd2.promise, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render( + fallbackElement...

} + /> + ); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root Loading... +

+
" + `); + + expect(consoleWarn).toHaveBeenCalledWith( + "`` is deprecated when using " + + "`v7_partialHydration`, use a `HydrateFallback` component instead" + ); + + dfd1.resolve("ROOT DATA"); + dfd2.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("does not re-run loaders that don't have loader data due to errors", async () => { + let spy = jest.fn(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: spy, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + ErrorBoundary() { + let error = useRouteError() as string; + return

{error}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + errors: { + index: "INDEX ERROR", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ INDEX ERROR +

+
" + `); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("lets users force hydration loader execution with loader.hydrate=true", async () => { + let dfd = createDeferred(); + let indexLoader: LoaderFunction = () => dfd.promise; + indexLoader.hydrate = true; + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: indexLoader, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + index: "INDEX INITIAL", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX INITIAL +

+
" + `); + + dfd.resolve("INDEX UPDATED"); + await waitFor(() => screen.getByText(/INDEX UPDATED/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX UPDATED +

+
" + `); + }); +} diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 2ae782598e..54f37a1121 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -634,7 +634,8 @@ export function RouterProvider({ React.useEffect(() => { warning( fallbackElement == null || !router.future.v7_partialHydration, - "`` is deprecated when using `v7_partialHydration`" + "`` is deprecated when using " + + "`v7_partialHydration`, use a `HydrateFallback` component instead" ); // Only log this once on initial mount // eslint-disable-next-line react-hooks/exhaustive-deps @@ -667,9 +668,6 @@ export function RouterProvider({ navigator, static: false, basename, - future: { - v7_relativeSplatPath: router.future.v7_relativeSplatPath, - }, }), [router, navigator, basename] ); @@ -691,8 +689,11 @@ export function RouterProvider({ location={state.location} navigationType={state.historyAction} navigator={navigator} + future={{ + v7_relativeSplatPath: router.future.v7_relativeSplatPath, + }} > - {state.initialized ? ( + {state.initialized || router.future.v7_partialHydration ? ( ` easier if relative routing would _replace_ the current splat: + + ```jsx + + + } /> + } /> + + + ``` + + Any paths like `/dashboard`, `/dashboard/team`, `/dashboard/projects` will match the `Dashboard` route. The dashboard component itself can then render nested ``: + + ```jsx + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + Now, all links and route paths are relative to the router above them. This makes code splitting and compartmentalizing your app really easy. You could render the `Dashboard` as its own independent app, or embed it into your large app without making any changes to it. + + **The Problem** + + The problem is that this concept of ignoring part of a path breaks a lot of other assumptions in React Router - namely that `"."` always means the current location pathname for that route. When we ignore the splat portion, we start getting invalid paths when using `"."`: + + ```jsx + // If we are on URL /dashboard/team, and we want to link to /dashboard/team: + function DashboardTeam() { + // ❌ This is broken and results in
+ return A broken link to the Current URL; + + // ✅ This is fixed but super unintuitive since we're already at /dashboard/team! + return A broken link to the Current URL; + } + ``` + + We've also introduced an issue that we can no longer move our `DashboardTeam` component around our route hierarchy easily - since it behaves differently if we're underneath a non-splat route, such as `/dashboard/:widget`. Now, our `"."` links will, properly point to ourself _inclusive of the dynamic param value_ so behavior will break from it's corresponding usage in a `/dashboard/*` route. + + Even worse, consider a nested splat route configuration: + + ```jsx + + + + } /> + + + + ``` + + Now, a `` and a `` inside the `Dashboard` component go to the same place! That is definitely not correct! + + Another common issue arose in Data Routers (and Remix) where any `
` should post to it's own route `action` if you the user doesn't specify a form action: + + ```jsx + let router = createBrowserRouter({ + path: "/dashboard", + children: [ + { + path: "*", + action: dashboardAction, + Component() { + // ❌ This form is broken! It throws a 405 error when it submits because + // it tries to submit to /dashboard (without the splat value) and the parent + // `/dashboard` route doesn't have an action + return ...
; + }, + }, + ], + }); + ``` + + This is just a compounded issue from the above because the default location for a `Form` to submit to is itself (`"."`) - and if we ignore the splat portion, that now resolves to the parent route. + + **The Solution** + If you are leveraging this behavior, it's recommended to enable the future flag, move your splat to it's own route, and leverage `../` for any links to "sibling" pages: + + ```jsx + + + + } /> + + + + + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + This way, `.` means "the full current pathname for my route" in all cases (including static, dynamic, and splat routes) and `..` always means "my parents pathname". + +### Patch Changes + +- Updated dependencies: + - `react-router@6.21.0` + ## 6.20.1 ### Patch Changes diff --git a/packages/react-router-native/package.json b/packages/react-router-native/package.json index d0aeb88446..619c72121c 100644 --- a/packages/react-router-native/package.json +++ b/packages/react-router-native/package.json @@ -1,6 +1,6 @@ { "name": "react-router-native", - "version": "6.20.1", + "version": "6.21.0", "description": "Declarative routing for React Native applications", "keywords": [ "react", @@ -22,7 +22,7 @@ "types": "./dist/index.d.ts", "dependencies": { "@ungap/url-search-params": "^0.2.2", - "react-router": "6.20.1" + "react-router": "6.21.0" }, "devDependencies": { "react": "^18.2.0", diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 03f5f7e9b5..23536881b2 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,146 @@ # `react-router` +## 6.21.0 + +### Minor Changes + +- Add a new `future.v7_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. ([#11087](https://github.com/remix-run/react-router/pull/11087)) + + This fix was originally added in [#10983](https://github.com/remix-run/react-router/issues/10983) and was later reverted in [#11078](https://github.com/remix-run/react-router/pull/11078) because it was determined that a large number of existing applications were relying on the buggy behavior (see [#11052](https://github.com/remix-run/react-router/issues/11052)) + + **The Bug** + The buggy behavior is that without this flag, the default behavior when resolving relative paths is to _ignore_ any splat (`*`) portion of the current route path. + + **The Background** + This decision was originally made thinking that it would make the concept of nested different sections of your apps in `` easier if relative routing would _replace_ the current splat: + + ```jsx + + + } /> + } /> + + + ``` + + Any paths like `/dashboard`, `/dashboard/team`, `/dashboard/projects` will match the `Dashboard` route. The dashboard component itself can then render nested ``: + + ```jsx + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + Now, all links and route paths are relative to the router above them. This makes code splitting and compartmentalizing your app really easy. You could render the `Dashboard` as its own independent app, or embed it into your large app without making any changes to it. + + **The Problem** + + The problem is that this concept of ignoring part of a path breaks a lot of other assumptions in React Router - namely that `"."` always means the current location pathname for that route. When we ignore the splat portion, we start getting invalid paths when using `"."`: + + ```jsx + // If we are on URL /dashboard/team, and we want to link to /dashboard/team: + function DashboardTeam() { + // ❌ This is broken and results in
+ return A broken link to the Current URL; + + // ✅ This is fixed but super unintuitive since we're already at /dashboard/team! + return A broken link to the Current URL; + } + ``` + + We've also introduced an issue that we can no longer move our `DashboardTeam` component around our route hierarchy easily - since it behaves differently if we're underneath a non-splat route, such as `/dashboard/:widget`. Now, our `"."` links will, properly point to ourself _inclusive of the dynamic param value_ so behavior will break from it's corresponding usage in a `/dashboard/*` route. + + Even worse, consider a nested splat route configuration: + + ```jsx + + + + } /> + + + + ``` + + Now, a `` and a `` inside the `Dashboard` component go to the same place! That is definitely not correct! + + Another common issue arose in Data Routers (and Remix) where any `
` should post to it's own route `action` if you the user doesn't specify a form action: + + ```jsx + let router = createBrowserRouter({ + path: "/dashboard", + children: [ + { + path: "*", + action: dashboardAction, + Component() { + // ❌ This form is broken! It throws a 405 error when it submits because + // it tries to submit to /dashboard (without the splat value) and the parent + // `/dashboard` route doesn't have an action + return ...
; + }, + }, + ], + }); + ``` + + This is just a compounded issue from the above because the default location for a `Form` to submit to is itself (`"."`) - and if we ignore the splat portion, that now resolves to the parent route. + + **The Solution** + If you are leveraging this behavior, it's recommended to enable the future flag, move your splat to it's own route, and leverage `../` for any links to "sibling" pages: + + ```jsx + + + + } /> + + + + + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + This way, `.` means "the full current pathname for my route" in all cases (including static, dynamic, and splat routes) and `..` always means "my parents pathname". + +### Patch Changes + +- Properly handle falsy error values in ErrorBoundary's ([#11071](https://github.com/remix-run/react-router/pull/11071)) +- Updated dependencies: + - `@remix-run/router@1.14.0` + ## 6.20.1 ### Patch Changes diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index f6bd81fa39..918bc34d39 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -116,7 +116,8 @@ export function RouterProvider({ React.useEffect(() => { warning( fallbackElement == null || !router.future.v7_partialHydration, - "`` is deprecated when using `v7_partialHydration`" + "`` is deprecated when using " + + "`v7_partialHydration`, use a `HydrateFallback` component instead" ); // Only log this once on initial mount // eslint-disable-next-line react-hooks/exhaustive-deps @@ -149,9 +150,6 @@ export function RouterProvider({ navigator, static: false, basename, - future: { - v7_relativeSplatPath: router.future.v7_relativeSplatPath, - }, }), [router, navigator, basename] ); @@ -171,8 +169,11 @@ export function RouterProvider({ location={state.location} navigationType={state.historyAction} navigator={navigator} + future={{ + v7_relativeSplatPath: router.future.v7_relativeSplatPath, + }} > - {state.initialized ? ( + {state.initialized || router.future.v7_partialHydration ? ( >; } /** diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index be756cf26e..a98168217a 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -70,7 +70,10 @@ export interface RouteMatch< export interface DataRouteMatch extends RouteMatch {} -export interface DataRouterContextObject extends NavigationContextObject { +export interface DataRouterContextObject + // Omit `future` since those can be pulled from the `router` + // `NavigationContext` needs future since it doesn't have a `router` in all cases + extends Omit { router: Router; staticContext?: StaticHandlerContext; } diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 63283f01c9..b3073ecd92 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "6.20.1", + "version": "6.21.0", "description": "Declarative routing for React", "keywords": [ "react", @@ -23,7 +23,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { - "@remix-run/router": "1.13.1" + "@remix-run/router": "1.14.0" }, "devDependencies": { "react": "^18.2.0" diff --git a/packages/router/CHANGELOG.md b/packages/router/CHANGELOG.md index 6099d96f17..821fe0aa56 100644 --- a/packages/router/CHANGELOG.md +++ b/packages/router/CHANGELOG.md @@ -1,5 +1,186 @@ # `@remix-run/router` +## 1.14.0 + +### Minor Changes + +- Added a new `future.v7_partialHydration` future flag that enables partial hydration of a data router when Server-Side Rendering. This allows you to provide `hydrationData.loaderData` that has values for _some_ initially matched route loaders, but not all. When this flag is enabled, the router will call `loader` functions for routes that do not have hydration loader data during `router.initialize()`, and it will render down to the deepest provided `HydrateFallback` (up to the first route without hydration data) while it executes the unhydrated routes. ([#11033](https://github.com/remix-run/react-router/pull/11033)) + + For example, the following router has a `root` and `index` route, but only provided `hydrationData.loaderData` for the `root` route. Because the `index` route has a `loader`, we need to run that during initialization. With `future.v7_partialHydration` specified, `` will render the `RootComponent` (because it has data) and then the `IndexFallback` (since it does not have data). Once `indexLoader` finishes, application will update and display `IndexComponent`. + + ```jsx + let router = createBrowserRouter( + [ + { + id: "root", + path: "/", + loader: rootLoader, + Component: RootComponent, + Fallback: RootFallback, + children: [ + { + id: "index", + index: true, + loader: indexLoader, + Component: IndexComponent, + HydrateFallback: IndexFallback, + }, + ], + }, + ], + { + future: { + v7_partialHydration: true, + }, + hydrationData: { + loaderData: { + root: { message: "Hydrated from Root!" }, + }, + }, + } + ); + ``` + + If the above example did not have an `IndexFallback`, then `RouterProvider` would instead render the `RootFallback` while it executed the `indexLoader`. + + **Note:** When `future.v7_partialHydration` is provided, the `` prop is ignored since you can move it to a `Fallback` on your top-most route. The `fallbackElement` prop will be removed in React Router v7 when `v7_partialHydration` behavior becomes the standard behavior. + +- Add a new `future.v7_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. ([#11087](https://github.com/remix-run/react-router/pull/11087)) + + This fix was originally added in [#10983](https://github.com/remix-run/react-router/issues/10983) and was later reverted in [#11078](https://github.com/remix-run/react-router/pull/11078) because it was determined that a large number of existing applications were relying on the buggy behavior (see [#11052](https://github.com/remix-run/react-router/issues/11052)) + + **The Bug** + The buggy behavior is that without this flag, the default behavior when resolving relative paths is to _ignore_ any splat (`*`) portion of the current route path. + + **The Background** + This decision was originally made thinking that it would make the concept of nested different sections of your apps in `` easier if relative routing would _replace_ the current splat: + + ```jsx + + + } /> + } /> + + + ``` + + Any paths like `/dashboard`, `/dashboard/team`, `/dashboard/projects` will match the `Dashboard` route. The dashboard component itself can then render nested ``: + + ```jsx + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + Now, all links and route paths are relative to the router above them. This makes code splitting and compartmentalizing your app really easy. You could render the `Dashboard` as its own independent app, or embed it into your large app without making any changes to it. + + **The Problem** + + The problem is that this concept of ignoring part of a path breaks a lot of other assumptions in React Router - namely that `"."` always means the current location pathname for that route. When we ignore the splat portion, we start getting invalid paths when using `"."`: + + ```jsx + // If we are on URL /dashboard/team, and we want to link to /dashboard/team: + function DashboardTeam() { + // ❌ This is broken and results in
+ return A broken link to the Current URL; + + // ✅ This is fixed but super unintuitive since we're already at /dashboard/team! + return A broken link to the Current URL; + } + ``` + + We've also introduced an issue that we can no longer move our `DashboardTeam` component around our route hierarchy easily - since it behaves differently if we're underneath a non-splat route, such as `/dashboard/:widget`. Now, our `"."` links will, properly point to ourself _inclusive of the dynamic param value_ so behavior will break from it's corresponding usage in a `/dashboard/*` route. + + Even worse, consider a nested splat route configuration: + + ```jsx + + + + } /> + + + + ``` + + Now, a `` and a `` inside the `Dashboard` component go to the same place! That is definitely not correct! + + Another common issue arose in Data Routers (and Remix) where any `
` should post to it's own route `action` if you the user doesn't specify a form action: + + ```jsx + let router = createBrowserRouter({ + path: "/dashboard", + children: [ + { + path: "*", + action: dashboardAction, + Component() { + // ❌ This form is broken! It throws a 405 error when it submits because + // it tries to submit to /dashboard (without the splat value) and the parent + // `/dashboard` route doesn't have an action + return ...
; + }, + }, + ], + }); + ``` + + This is just a compounded issue from the above because the default location for a `Form` to submit to is itself (`"."`) - and if we ignore the splat portion, that now resolves to the parent route. + + **The Solution** + If you are leveraging this behavior, it's recommended to enable the future flag, move your splat to it's own route, and leverage `../` for any links to "sibling" pages: + + ```jsx + + + + } /> + + + + + function Dashboard() { + return ( +
+

Dashboard

+ + + + } /> + } /> + } /> + +
+ ); + } + ``` + + This way, `.` means "the full current pathname for my route" in all cases (including static, dynamic, and splat routes) and `..` always means "my parents pathname". + +### Patch Changes + +- Catch and bubble errors thrown when trying to unwrap responses from `loader`/`action` functions ([#11061](https://github.com/remix-run/react-router/pull/11061)) +- Fix `relative="path"` issue when rendering `Link`/`NavLink` outside of matched routes ([#11062](https://github.com/remix-run/react-router/pull/11062)) + ## 1.13.1 ### Patch Changes diff --git a/packages/router/__tests__/route-fallback-test.ts b/packages/router/__tests__/route-fallback-test.ts index dd2b672497..b4afc25419 100644 --- a/packages/router/__tests__/route-fallback-test.ts +++ b/packages/router/__tests__/route-fallback-test.ts @@ -251,7 +251,7 @@ describe("future.v7_partialHydration", () => { }); describe("when set to true", () => { - it("starts with initialized=true, runs unhydrated loaders with partial hydrationData", async () => { + it("starts with initialized=false, runs unhydrated loaders with partial hydrationData", async () => { let spy = jest.fn(); let shouldRevalidateSpy = jest.fn((args) => args.defaultShouldRevalidate); let dfd = createDeferred(); @@ -291,7 +291,7 @@ describe("future.v7_partialHydration", () => { historyAction: "POP", location: { pathname: "/" }, loaderData: { root: "LOADER DATA" }, - initialized: true, + initialized: false, navigation: { state: "idle" }, }); @@ -322,7 +322,7 @@ describe("future.v7_partialHydration", () => { }); }); - it("starts with initialized=true, runs hydrated loaders when loader.hydrate=true", async () => { + it("starts with initialized=false, runs hydrated loaders when loader.hydrate=true", async () => { let spy = jest.fn(); let shouldRevalidateSpy = jest.fn((args) => args.defaultShouldRevalidate); let dfd = createDeferred(); @@ -367,7 +367,7 @@ describe("future.v7_partialHydration", () => { root: "LOADER DATA", index: "INDEX INITIAL", }, - initialized: true, + initialized: false, navigation: { state: "idle" }, }); diff --git a/packages/router/package.json b/packages/router/package.json index 4f452de03e..1e4fdd17eb 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/router", - "version": "1.13.1", + "version": "1.14.0", "description": "Nested/Data-driven/Framework-agnostic Routing", "keywords": [ "remix", diff --git a/packages/router/router.ts b/packages/router/router.ts index a00f54e627..cfc64ce0c3 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -817,19 +817,34 @@ export function createRouter(init: RouterInit): Router { initialErrors = { [route.id]: error }; } - // "Initialized" here really means "Can `RouterProvider` render my route tree?" - // Prior to `route.HydrateFallback`, we only had a root `fallbackElement` so we used - // `state.initialized` to render that instead of ``. Now that we - // support route level fallbacks we can always render and we'll just render - // as deep as we have data for and detect the nearest ancestor HydrateFallback - let initialized = - future.v7_partialHydration || + let initialized: boolean; + let hasLazyRoutes = initialMatches.some((m) => m.route.lazy); + let hasLoaders = initialMatches.some((m) => m.route.loader); + if (hasLazyRoutes) { // All initialMatches need to be loaded before we're ready. If we have lazy // functions around still then we'll need to run them in initialize() - (!initialMatches.some((m) => m.route.lazy) && - // And we have to either have no loaders or have been provided hydrationData - (!initialMatches.some((m) => m.route.loader) || - init.hydrationData != null)); + initialized = false; + } else if (!hasLoaders) { + // If we've got no loaders to run, then we're good to go + initialized = true; + } else if (future.v7_partialHydration) { + // If partial hydration is enabled, we're initialized so long as we were + // provided with hydrationData for every route with a loader, and no loaders + // were marked for explicit hydration + let loaderData = init.hydrationData ? init.hydrationData.loaderData : null; + let errors = init.hydrationData ? init.hydrationData.errors : null; + initialized = initialMatches.every( + (m) => + m.route.loader && + m.route.loader.hydrate !== true && + ((loaderData && loaderData[m.route.id] !== undefined) || + (errors && errors[m.route.id] !== undefined)) + ); + } else { + // Without partial hydration - we're initialized if we were provided any + // hydrationData - which is expected to be complete + initialized = init.hydrationData != null; + } let router: Router; let state: RouterState = { @@ -1010,11 +1025,7 @@ export function createRouter(init: RouterInit): Router { // in the normal navigation flow. For SSR it's expected that lazy modules are // resolved prior to router creation since we can't go into a fallbackElement // UI for SSR'd apps - if ( - !state.initialized || - (future.v7_partialHydration && - state.matches.some((m) => isUnhydratedRoute(state, m.route))) - ) { + if (!state.initialized) { startNavigation(HistoryAction.Pop, state.location, { initialHydration: true, });