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/components/form.md b/docs/components/form.md index 65336f3b09..d5626d753a 100644 --- a/docs/components/form.md +++ b/docs/components/form.md @@ -109,6 +109,8 @@ If you need to post to a different route, then add an action prop: - [Index Search Param][indexsearchparam] (index vs parent route disambiguation) +Please see the [Splat Paths][relativesplatpath] section on the `useResolvedPath` docs for a note on the behavior of the `future.v7_relativeSplatPath` future flag for relative `useNavigate()` behavior within splat routes + ## `method` This determines the [HTTP verb](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) to be used. The same as plain HTML [form method][htmlform-method], except it also supports "put", "patch", and "delete" in addition to "get" and "post". The default is "get". @@ -394,3 +396,4 @@ You can access those values from the `request.url` [history-state]: https://developer.mozilla.org/en-US/docs/Web/API/History/state [use-view-transition-state]: ../hooks//use-view-transition-state [view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API +[relativesplatpath]: ../hooks/use-resolved-path#splat-paths diff --git a/docs/components/link.md b/docs/components/link.md index 9b415331f7..70a50876cd 100644 --- a/docs/components/link.md +++ b/docs/components/link.md @@ -63,6 +63,8 @@ A relative `` value (that does not begin with `/`) resolves relative to `` with a `..` behaves differently from a normal `` when the current URL ends with `/`. `` ignores the trailing slash, and removes one URL segment for each `..`. But an `` value handles `..` differently when the current URL ends with `/` vs when it does not. +Please see the [Splat Paths][relativesplatpath] section on the `useResolvedPath` docs for a note on the behavior of the `future.v7_relativeSplatPath` future flag for relative `` behavior within splat routes + ## `relative` By default, links are relative to the route hierarchy (`relative="route"`), so `..` will go up one `Route` level from the current contextual route. Occasionally, you may find that you have matching URL patterns that do not make sense to be nested, and you'd prefer to use relative _path_ routing from the current contextual route path. You can opt into this behavior with `relative="path"`: @@ -202,3 +204,4 @@ function ImageLink(to) { [view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API [picking-a-router]: ../routers/picking-a-router [navlink]: ./nav-link +[relativesplatpath]: ../hooks/use-resolved-path#splat-paths diff --git a/docs/guides/api-development-strategy.md b/docs/guides/api-development-strategy.md index 7ef5cd4ab2..1944448301 100644 --- a/docs/guides/api-development-strategy.md +++ b/docs/guides/api-development-strategy.md @@ -63,11 +63,13 @@ const router = createBrowserRouter(routes, { }); ``` -| Flag | Description | -| ------------------------ | --------------------------------------------------------------------- | -| `v7_fetcherPersist` | Delay active fetcher cleanup until they return to an `idle` state | -| `v7_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method | -| `v7_prependBasename` | Prepend the router basename to navigate/fetch paths | +| Flag | Description | +| ------------------------------------------- | --------------------------------------------------------------------- | +| `v7_fetcherPersist` | Delay active fetcher cleanup until they return to an `idle` state | +| `v7_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method | +| [`v7_partialHydration`][partialhydration] | Support partial hydration for Server-rendered apps | +| `v7_prependBasename` | Prepend the router basename to navigate/fetch paths | +| [`v7_relativeSplatPath`][relativesplatpath] | Fix buggy relative path resolution in splat routes | ### React Router Future Flags @@ -94,3 +96,5 @@ These flags apply to both Data and non-Data Routers and are passed to the render [feature-flowchart]: https://remix.run/docs-images/feature-flowchart.png [picking-a-router]: ../routers/picking-a-router [starttransition]: https://react.dev/reference/react/startTransition +[partialhydration]: ../routers/create-browser-router#partial-hydration-data +[relativesplatpath]: ../hooks/use-resolved-path#splat-paths diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index fae8ee1abb..ede4d67e08 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -165,6 +165,14 @@ And with that you've got a server-side-rendered and hydrated application! For a As mentioned above, server-side rendering is tricky at scale and for production-grade applications, and we strongly recommend checking out [Remix][remix] if that's your goal. But if you are going the manual route, here's a few additional concepts you may need to consider: +#### Hydration + +A core concept of Server Side Rendering is [hydration][hydration] which involves "attaching" a client-side React application to server-rendered HTML. To do this correctly, we need to create our client-side React Router application in the same state that it was in during the server render. When your server render loaded data via `loader` functions, we need to send this data up so that we can create our client router with the same loader data for the initial render/hydration. + +The basic usages of `` and `createBrowserRouter` shown in this guide handle this for you internally, but if you need to take control over the hydration process you can disable the automatic hydration process via [``][hydrate-false]. + +In some advanced use cases, you may want to partially hydrate a client-side React Router application. You can do this via the [`future.v7_partialHydration`][partialhydration] flag passed to `createBrowserRouter`. + #### Redirects If any loaders redirect, `handler.query` will return the `Response` directly so you should check that and send a redirect response instead of attempting to render an HTML document: @@ -309,3 +317,6 @@ Again, we recommend you give [Remix](https://remix.run) a look. It's the best wa [createstaticrouter]: ../routers/create-static-router [staticrouterprovider]: ../routers/static-router-provider [lazy]: ../route/lazy +[hydration]: https://react.dev/reference/react-dom/client/hydrateRoot +[hydrate-false]: ../routers/static-router-provider#hydrate +[partialhydration]: ../routers/create-browser-router#partial-hydration-data diff --git a/docs/hooks/use-href.md b/docs/hooks/use-href.md index 680add0450..842e2e889d 100644 --- a/docs/hooks/use-href.md +++ b/docs/hooks/use-href.md @@ -18,8 +18,8 @@ declare function useHref( The `useHref` hook returns a URL that may be used to link to the given `to` location, even outside of React Router. -> **Tip:** -> -> You may be interested in taking a look at the source for the `` -> component in `react-router-dom` to see how it uses `useHref` internally to -> determine its own `href` value. +You may be interested in taking a look at the source for the `` component in `react-router-dom` to see how it uses `useHref` internally to determine its own `href` value + +Please see the [Splat Paths][relativesplatpath] section on the `useResolvedPath` docs for a note on the behavior of the `future.v7_relativeSplatPath` future flag for relative `useHref()` behavior within splat routes + +[relativesplatpath]: ../hooks/use-resolved-path#splat-paths diff --git a/docs/hooks/use-navigate.md b/docs/hooks/use-navigate.md index d8e25cabd8..4493b90228 100644 --- a/docs/hooks/use-navigate.md +++ b/docs/hooks/use-navigate.md @@ -54,6 +54,8 @@ The `navigate` function has two signatures: - Either pass a `To` value (same type as ``) with an optional second `options` argument (similar to the props you can pass to [``][link]), or - Pass the delta you want to go in the history stack. For example, `navigate(-1)` is equivalent to hitting the back button +Please see the [Splat Paths][relativesplatpath] section on the `useResolvedPath` docs for a note on the behavior of the `future.v7_relativeSplatPath` future flag for relative `useNavigate()` behavior within splat routes + ## `options.replace` Specifying `replace: true` will cause the navigation to replace the current entry in the history stack instead of adding a new one. @@ -119,3 +121,4 @@ The `unstable_viewTransition` option enables a [View Transition][view-transition [picking-a-router]: ../routers/picking-a-router [flush-sync]: https://react.dev/reference/react-dom/flushSync [start-transition]: https://react.dev/reference/react/startTransition +[relativesplatpath]: ../hooks/use-resolved-path#splat-paths diff --git a/docs/hooks/use-resolved-path.md b/docs/hooks/use-resolved-path.md index fcf4138809..efc70f8e71 100644 --- a/docs/hooks/use-resolved-path.md +++ b/docs/hooks/use-resolved-path.md @@ -22,5 +22,64 @@ This is useful when building links from relative values. For example, check out See [resolvePath][resolvepath] for more information. +## Splat Paths + +The original logic for `useResolvedPath` behaved differently for splat paths which in hindsight was incorrect/buggy behavior. This was fixed in [`6.19.0`][release-6.19.0] but it was determined that a large number of existing applications [relied on this behavior][revert-comment] so the fix was reverted in [`6.20.1`][release-6.20.1] and re-introduced in [`6.21.0`][release-6.21.0] behind a `future.v7_relativeSplatPath` [future flag][future-flag]. This will become the default behavior in React Router v7, so it is recommended to update your applications at your convenience to be better prepared for the eventual v7 upgrade. + +It should be noted that this is the foundation for all relative routing in React Router, so this applies to the following relative path code flows as well: + +- `` +- `useNavigate()` +- `useHref()` +- `
` +- `useSubmit()` +- Relative path `redirect` responses returned from loaders and actions + +### Behavior without the flag + +When this flag is not enabled, the default behavior is that when resolving relative paths inside of a [splat route (`*`)][splat], the splat portion of the path is ignored. So, given a route tree such as: + +```jsx + + + } /> + + +``` + +If you are currently at URL `/dashboard/teams`, `useResolvedPath("projects")` inside the `Dashboard` component would resolve to `/dashboard/projects` because the "current" location we are relative to would be considered `/dashboard` _without the "teams" splat value_. + +This makes for a slight convenience in routing between "sibling" splat routes (`/dashboard/teams`, `/dashboard/projects`, etc.), however it causes other inconsistencies such as: + +- `useResolvedPath(".")` no longer resolves to the current location for that route, it actually resolved you "up" to `/dashboard` from `/dashboard/teams` +- If you changed your route definition to use a dynamic parameter (``), then any resolved paths inside the `Dashboard` component would break since the dynamic param value is not ignored like the splat value + +And then it gets worse if you define the splat route as a child: + +```jsx + + + + } /> + + + +``` + +- Now, `useResolvedPath(".")` and `useResolvedPath("..")` resolve to the exact same path inside `` +- If you were using a Data Router and defined an `action` on the splat route, you'd get a 405 error on `` submissions because they (by default) submit to `"."` which would resolve to the parent `/dashboard` route which doesn't have an `action`. + +### Behavior with the flag + +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. + [navlink]: ../components/nav-link [resolvepath]: ../utils/resolve-path +[release-6.19.0]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v6190 +[release-6.20.1]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v6201 +[release-6.21.0]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v6210 +[revert-comment]: https://github.com/remix-run/react-router/issues/11052#issuecomment-1836589329 +[future-flag]: ../guides/api-development-strategy +[splat]: ../route/route#splats diff --git a/docs/hooks/use-submit.md b/docs/hooks/use-submit.md index 5208632166..83e0a14650 100644 --- a/docs/hooks/use-submit.md +++ b/docs/hooks/use-submit.md @@ -150,6 +150,8 @@ submit(null, { ; ``` +Please see the [Splat Paths][relativesplatpath] section on the `useResolvedPath` docs for a note on the behavior of the `future.v7_relativeSplatPath` future flag for relative `useSubmit()` `action` behavior within splat routes + Because submissions are navigations, the options may also contain the other navigation related props from [``][form] such as: - `fetcherKey` @@ -170,3 +172,4 @@ The `unstable_flushSync` option tells React Router DOM to wrap the initial state [form]: ../components/form [flush-sync]: https://react.dev/reference/react-dom/flushSync [start-transition]: https://react.dev/reference/react/startTransition +[relativesplatpath]: ../hooks/use-resolved-path#splat-paths diff --git a/docs/route/hydrate-fallback-element.md b/docs/route/hydrate-fallback-element.md new file mode 100644 index 0000000000..c50034862e --- /dev/null +++ b/docs/route/hydrate-fallback-element.md @@ -0,0 +1,51 @@ +--- +title: hydrateFallbackElement +new: true +--- + +# `hydrateFallbackElement` + +If you are using [Server-Side Rendering][ssr] and you are leveraging [partial hydration][partialhydration], then you can specify an Element/Component to render for non-hydrated routes during the initial hydration of the application. + +If you do not wish to specify a React element (i.e., `hydrateFallbackElement={}`) you may specify an `HydrateFallback` component instead (i.e., `HydrateFallback={MyFallback}`) and React Router will call `createElement` for you internally. + +This feature only works if using a data router, see [Picking a Router][pickingarouter] + +```tsx +let router = createBrowserRouter( + [ + { + id: "root", + path: "/", + loader: rootLoader, + Component: Root, + children: [ + { + id: "invoice", + path: "invoices/:id", + loader: loadInvoice, + Component: Invoice, + HydrateFallback: InvoiceSkeleton, + }, + ], + }, + ], + { + future: { + v7_partialHydration: true, + }, + hydrationData: { + root: { + /*...*/ + }, + // No hydration data provided for the `invoice` route + }, + } +); +``` + +There is no default fallback and it will just render `null` at that route level, so it is recommended that you always provide your own fallback element. + +[pickingarouter]: ../routers/picking-a-router +[ssr]: ../guides/ssr +[partialhydration]: ../routers/create-browser-router#partial-hydration-data diff --git a/docs/route/loader.md b/docs/route/loader.md index 5951cec118..2dca998e41 100644 --- a/docs/route/loader.md +++ b/docs/route/loader.md @@ -85,6 +85,10 @@ function loader({ request }) { Note that the APIs here are not React Router specific, but rather standard web objects: [Request][request], [URL][url], [URLSearchParams][urlsearchparams]. +## `loader.hydrate` + +If you are [Server-Side Rendering][ssr] and leveraging the `fututre.v7_partialHydration` flag for [Partial Hydration][partialhydration], then you may wish to opt-into running a route `loader` on initial hydration _even though it has hydration data_ (for example, to let a user prime a cache with the hydration data). To force a `loader` to run on hydration in a partial hydration scenario, you can set a `hydrate` property on the `loader` function: + ## Returning Responses While you can return anything you want from a loader and get access to it from [`useLoaderData`][useloaderdata], you can also return a web [Response][response]. @@ -174,3 +178,5 @@ For more details, read the [`errorElement`][errorelement] documentation. [json]: ../fetch/json [errorelement]: ./error-element [pickingarouter]: ../routers/picking-a-router +[ssr]: ../guides/ssr.md +[partialhydration]: ../routers/create-browser-router#partial-hydration-data diff --git a/docs/route/route.md b/docs/route/route.md index 8201f3192d..e3262bb432 100644 --- a/docs/route/route.md +++ b/docs/route/route.md @@ -76,8 +76,10 @@ interface RouteObject { loader?: LoaderFunction; action?: ActionFunction; element?: React.ReactNode | null; - Component?: React.ComponentType | null; + hydrateFallbackElement?: React.ReactNode | null; errorElement?: React.ReactNode | null; + Component?: React.ComponentType | null; + HydrateFallback?: React.ComponentType | null; ErrorBoundary?: React.ComponentType | null; handle?: RouteObject["handle"]; shouldRevalidate?: ShouldRevalidateFunction; @@ -354,6 +356,16 @@ Otherwise use `ErrorBoundary` and React Router will create the React Element for Please see the [errorElement][errorelement] documentation for more details. +## `hydrateFallbackElement`/`HydrateFallback` + +If you are using [Server-Side Rendering][ssr] and you are leveraging [partial hydration][partialhydration], then you can specify an Element/Component to render for non-hydrated routes during the initial hydration of the application. + +If you are not using a data router like [`createBrowserRouter`][createbrowserrouter], this will do nothing + +This is only intended for more advanced uses cases such as Remix's [`clientLoader`][clientloader] functionality. Most SSR apps will not need to leverage these route properties. + +Please see the [hydrateFallbackElement][hydratefallbackelement] documentation for more details. + ## `handle` Any application-specific data. Please see the [useMatches][usematches] documentation for details and examples. @@ -404,6 +416,7 @@ Please see the [lazy][lazy] documentation for more details. [loader]: ./loader [action]: ./action [errorelement]: ./error-element +[hydratefallbackelement]: ./hydrate-fallback-element [form]: ../components/form [fetcher]: ../hooks/use-fetcher [usesubmit]: ../hooks/use-submit @@ -411,3 +424,6 @@ Please see the [lazy][lazy] documentation for more details. [createbrowserrouter]: ../routers/create-browser-router [usematches]: ../hooks/use-matches [lazy]: ./lazy +[ssr]: ../guides/ssr +[partialhydration]: ../routers/create-browser-router#partial-hydration-data +[clientloader]: https://remix.run/route/client-loader diff --git a/docs/routers/create-browser-router.md b/docs/routers/create-browser-router.md index 1b3521a16e..37ff81a581 100644 --- a/docs/routers/create-browser-router.md +++ b/docs/routers/create-browser-router.md @@ -120,8 +120,66 @@ The following future flags are currently available: | ------------------------ | --------------------------------------------------------------------- | | `v7_fetcherPersist` | Delay active fetcher cleanup until they return to an `idle` state | | `v7_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method | +| `v7_partialHydration` | Support partial hydration for Server-rendered apps | | `v7_prependBasename` | Prepend the router basename to navigate/fetch paths | +## `hydrationData` + +When [Server-Rendering][ssr] and [opting-out of automatic hydration][hydrate-false], the `hydrationData` option allows you to pass in hydration data from your server-render. This will almost always be a subset of data from the `StaticHandlerContext` value you get back from [handler.query][query]: + +```js +const router = createBrowserRouter(routes, { + hydrationData: { + loaderData: { + // [routeId]: serverLoaderData + }, + // may also include `errors` and/or `actionData` + }, +}); +``` + +### Partial Hydration Data + +You will almost always include a complete set of `loaderData` to hydrate a server-rendered app. But in advanced use-cases (such as Remix's [`clientLoader`][clientloader]), you may want to include `loaderData` for only _some_ routes that were rendered on the server. If you want to enable partial `loaderData` and opt-into granular [`route.HydrateFallback`][hydratefallback] usage, you will need to enable the `future.v7_partialHydration` flag. Prior to this flag, any provided `loaderData` was assumed to be complete and would not result in the execution of route loaders on initial hydration. + +When this flag is specified, loaders will run on initial hydration in 2 scenarios: + +- No hydration data is provided + - In these cases the `HydrateFallback` component will render on initial hydration +- The `loader.hydrate` property is set to `true` + - This allows you to run the `loader` even if you did not render a fallback on initial hydration (i.e., to prime a cache with hydration data) + +```js +const router = createBrowserRouter( + [ + { + id: "root", + loader: rootLoader, + Component: Root, + children: [ + { + id: "index", + loader: indexLoader, + HydrateFallback: IndexSkeleton, + Component: Index, + }, + ], + }, + ], + { + future: { + v7_partialHydration: true, + }, + hydrationData: { + loaderData: { + root: "ROOT DATA", + // No index data provided + }, + }, + } +); +``` + ## `window` Useful for environments like browser devtool plugins or testing to use a different window than the global `window`. @@ -134,3 +192,8 @@ Useful for environments like browser devtool plugins or testing to use a differe [api-development-strategy]: ../guides/api-development-strategy [remixing-react-router]: https://remix.run/blog/remixing-react-router [when-to-fetch]: https://www.youtube.com/watch?v=95B8mnhzoCM +[ssr]: ../guides/ssr +[hydrate-false]: ../routers/static-router-provider#hydrate +[query]: ./create-static-handler#handlerqueryrequest-opts +[clientloader]: https://remix.run/route/client-loader +[hydratefallback]: ../route/hydrate-fallback-element diff --git a/docs/routers/create-static-router.md b/docs/routers/create-static-router.md index 37aba69cbc..7d42adb06c 100644 --- a/docs/routers/create-static-router.md +++ b/docs/routers/create-static-router.md @@ -55,10 +55,34 @@ export async function renderHtml(req) { ```ts declare function createStaticRouter( routes: RouteObject[], - context: StaticHandlerContext + context: StaticHandlerContext, + opts: { + future?: { + v7_partialHydration?: boolean; + }; + } ): Router; ``` +## `opts.future` + +An optional set of [Future Flags][api-development-strategy] to enable for this Static Router. We recommend opting into newly released future flags sooner rather than later to ease your eventual migration to v7. + +```js +const router = createBrowserRouter(routes, { + future: { + // Opt-into partial hydration + v7_partialHydration: true, + }, +}); +``` + +The following future flags are currently available: + +| Flag | Description | +| ----------------------------------------- | -------------------------------------------------- | +| [`v7_partialHydration`][partialhydration] | Support partial hydration for Server-rendered apps | + **See also:** - [`createStaticHandler`][createstatichandler] @@ -69,3 +93,5 @@ declare function createStaticRouter( [ssr]: ../guides/ssr [createstatichandler]: ../routers/create-static-handler [staticrouterprovider]: ../routers/static-router-provider +[partialhydration]: ../routers/create-browser-router#partial-hydration-data +[api-development-strategy]: ../guides/api-development-strategy diff --git a/package.json b/package.json index ce06a4af7e..a87b49acfe 100644 --- a/package.json +++ b/package.json @@ -110,19 +110,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "49.3 kB" + "none": "50.4 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "13.9 kB" + "none": "14.7 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "16.3 kB" + "none": "17.1 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "16.7 kB" + "none": "16.9 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "22.9 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/index.ts b/packages/react-router-dom-v5-compat/index.ts index a9e2a15c41..6de7e9dee8 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -59,6 +59,7 @@ export type { FormEncType, FormMethod, FormProps, + FutureConfig, GetScrollRestorationKeyFunction, Hash, HashRouterProps, 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 8bd9e7b1b0..9a3571fdf5 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -1660,14 +1660,12 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( }> - "index"} element={

index

} /> - "1"} element={

Page 1

} /> - "2"} element={

Page 2

} /> + index} /> + Page 1} /> + Page 2} /> ), - { - hydrationData: {}, - } + {} ); let { container } = render(); @@ -1729,7 +1727,7 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( }> - "index"} element={

Index Page

} /> + Index Page} /> "action data"} @@ -1738,9 +1736,7 @@ function testDomRouter( Result Page} /> ), - { - hydrationData: {}, - } + {} ); let { container } = render(); @@ -1794,7 +1790,7 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( }> - "index"} element={

Index Page

} /> + Index Page} /> @@ -1859,13 +1855,12 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( }> - "index"} element={

index

} /> + index} /> "1"} element={

Page 1

} /> "2"} element={

Page 2

} />
), { - hydrationData: {}, window: getWindow("/"), } ); @@ -1933,18 +1928,12 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( }> - "index"} element={

index

} /> - "1"} element={

Page 1

} /> - "action"} - loader={() => "2"} - element={

Page 2

} - /> + index} /> + Page 1} /> + "action"} element={

Page 2

} />
), { - hydrationData: {}, window: getWindow("/"), } ); @@ -2028,7 +2017,7 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( }> - "index"} element={

index

} /> + index} /> "action"} @@ -2038,7 +2027,6 @@ function testDomRouter( ), { - hydrationData: {}, window: getWindow("/"), } ); @@ -2136,7 +2124,7 @@ function testDomRouter( let testWindow = getWindow("/base/path"); let router = createTestRouter( createRoutesFromElements(} />), - { basename: "/base", hydrationData: {}, window: testWindow } + { basename: "/base", window: testWindow } ); let { container } = render(); @@ -2213,7 +2201,7 @@ function testDomRouter( ), { basename: "/base", - hydrationData: {}, + window: testWindow, } ); @@ -2296,8 +2284,9 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( { + action={async () => { throw new Error("Should not hit this"); }} loader={() => loaderDefer.promise} @@ -2305,7 +2294,7 @@ function testDomRouter( /> ), { - hydrationData: {}, + hydrationData: { loaderData: { index: "Initial Data" } }, window: getWindow("/"), } ); @@ -2339,7 +2328,9 @@ function testDomRouter(

idle

-

+

+ Initial Data +

" `); @@ -2353,7 +2344,9 @@ function testDomRouter(

loading

-

+

+ Initial Data +

" `); @@ -2444,6 +2437,7 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( { let resolvedValue = await actionDefer.promise; @@ -2455,7 +2449,7 @@ function testDomRouter( /> ), { - hydrationData: {}, + hydrationData: { loaderData: { index: "Initial Data" } }, window: getWindow("/"), } ); @@ -3840,12 +3834,14 @@ function testDomRouter( let router = createTestRouter( createRoutesFromElements( } action={() => "PARENT ACTION"} loader={() => "PARENT LOADER"} > } action={() => "INDEX ACTION"} @@ -4773,6 +4769,7 @@ function testDomRouter( createRoutesFromElements( <> } action={async ({ request }) => { @@ -4791,7 +4788,7 @@ function testDomRouter( ), { window: getWindow("/"), - hydrationData: { loaderData: { "0": null } }, + hydrationData: { loaderData: { index: null } }, } ); let { container } = render(); @@ -5441,7 +5438,9 @@ function testDomRouter( expect(getHtml(container)).toMatch("Page"); // Resolve after the navigation - no-op - expect(loaderRequest?.signal?.aborted).toBe(true); + expect((loaderRequest as unknown as Request)?.signal?.aborted).toBe( + true + ); dfd.resolve("FETCH"); await waitFor(() => screen.getByText("Num fetchers: 0")); expect(getHtml(container)).toMatch("Page"); diff --git a/packages/react-router-dom/__tests__/link-href-test.tsx b/packages/react-router-dom/__tests__/link-href-test.tsx index 762f972ddc..06dd12ae31 100644 --- a/packages/react-router-dom/__tests__/link-href-test.tsx +++ b/packages/react-router-dom/__tests__/link-href-test.tsx @@ -927,4 +927,37 @@ describe(" href", () => { ); warnSpy.mockRestore(); }); + + test("renders fine when used outside a route context", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + Route + + Path + + + ); + }); + + let anchors = renderer.root.findAllByType("a"); + expect(anchors.map((a) => ({ href: a.props.href, text: a.children }))) + .toMatchInlineSnapshot(` + [ + { + "href": "/route", + "text": [ + "Route", + ], + }, + { + "href": "/path", + "text": [ + "Path", + ], + }, + ] + `); + }); }); 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 c3a1bd947c..54f37a1121 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -104,6 +104,7 @@ export type { DataRouteObject, ErrorResponse, Fetcher, + FutureConfig, Hash, IndexRouteObject, IndexRouteProps, @@ -630,6 +631,16 @@ export function RouterProvider({ } }, [vtContext.isTransitioning, interruption]); + React.useEffect(() => { + warning( + fallbackElement == null || !router.future.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 + }, []); + let navigator = React.useMemo((): Navigator => { return { createHref: router.createHref, @@ -678,9 +689,16 @@ 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 ? ( + ) : ( fallbackElement )} @@ -696,12 +714,14 @@ export function RouterProvider({ function DataRoutes({ routes, + future, state, }: { routes: DataRouteObject[]; + future: RemixRouter["future"]; state: RouterState; }): React.ReactElement | null { - return useRoutesImpl(routes, undefined, state); + return useRoutesImpl(routes, undefined, state, future); } export interface BrowserRouterProps { @@ -749,6 +769,7 @@ export function BrowserRouter({ location={state.location} navigationType={state.action} navigator={history} + future={future} /> ); } @@ -799,6 +820,7 @@ export function HashRouter({ location={state.location} navigationType={state.action} navigator={history} + future={future} /> ); } @@ -845,6 +867,7 @@ function HistoryRouter({ location={state.location} navigationType={state.action} navigator={history} + future={future} /> ); } @@ -1543,10 +1566,8 @@ export function useFormAction( // object referenced by useMemo inside useResolvedPath let path = { ...useResolvedPath(action ? action : ".", { relative }) }; - // Previously we set the default action to ".". The problem with this is that - // `useResolvedPath(".")` excludes search params of the resolved URL. This is - // the intended behavior of when "." is specifically provided as - // the form action, but inconsistent w/ browsers when the action is omitted. + // If no action was specified, browsers will persist current search params + // when determining the path, so match that behavior // https://github.com/remix-run/remix/issues/927 let location = useLocation(); if (action == null) { diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index 97ee5d76d1..ff0b4cb2e7 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "6.20.1", + "version": "6.21.0", "description": "Declarative routing for React web applications", "keywords": [ "react", @@ -23,8 +23,8 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { - "@remix-run/router": "1.13.1", - "react-router": "6.20.1" + "@remix-run/router": "1.14.0", + "react-router": "6.21.0" }, "devDependencies": { "react": "^18.2.0", diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 99fc1426dc..6b75be9ed9 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -7,6 +7,7 @@ import type { CreateStaticHandlerOptions as RouterCreateStaticHandlerOptions, UNSAFE_RouteManifest as RouteManifest, RouterState, + FutureConfig as RouterFutureConfig, } from "@remix-run/router"; import { IDLE_BLOCKER, @@ -24,6 +25,7 @@ import { } from "react-router"; import type { DataRouteObject, + FutureConfig, Location, RouteObject, To, @@ -42,6 +44,7 @@ export interface StaticRouterProps { basename?: string; children?: React.ReactNode; location: Partial | string; + future?: Partial; } /** @@ -52,6 +55,7 @@ export function StaticRouter({ basename, children, location: locationProp = "/", + future, }: StaticRouterProps) { if (typeof locationProp === "string") { locationProp = parsePath(locationProp); @@ -74,6 +78,7 @@ export function StaticRouter({ location={location} navigationType={action} navigator={staticNavigator} + future={future} static={true} /> ); @@ -143,8 +148,15 @@ export function StaticRouterProvider({ navigationType={state.historyAction} navigator={dataRouterContext.navigator} static={dataRouterContext.static} + future={{ + v7_relativeSplatPath: router.future.v7_relativeSplatPath, + }} > - + @@ -163,12 +175,14 @@ export function StaticRouterProvider({ function DataRoutes({ routes, + future, state, }: { routes: DataRouteObject[]; + future: RemixRouter["future"]; state: RouterState; }): React.ReactElement | null { - return useRoutesImpl(routes, undefined, state); + return useRoutesImpl(routes, undefined, state, future); } function serializeErrors( @@ -260,7 +274,13 @@ export function createStaticHandler( export function createStaticRouter( routes: RouteObject[], - context: StaticHandlerContext + context: StaticHandlerContext, + opts: { + // Only accept future flags that impact the server render + future?: Partial< + Pick + >; + } = {} ): RemixRouter { let manifest: RouteManifest = {}; let dataRoutes = convertRoutesToDataRoutes( @@ -288,6 +308,15 @@ export function createStaticRouter( get basename() { return context.basename; }, + get future() { + return { + v7_fetcherPersist: false, + v7_normalizeFormMethod: false, + v7_partialHydration: opts.future?.v7_partialHydration === true, + v7_prependBasename: false, + v7_relativeSplatPath: opts.future?.v7_relativeSplatPath === true, + }; + }, get state() { return { historyAction: Action.Pop, diff --git a/packages/react-router-native/CHANGELOG.md b/packages/react-router-native/CHANGELOG.md index 70799316a0..f733affd5e 100644 --- a/packages/react-router-native/CHANGELOG.md +++ b/packages/react-router-native/CHANGELOG.md @@ -1,5 +1,145 @@ # `react-router-native` +## 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@6.21.0` + ## 6.20.1 ### Patch Changes diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index 6c52f5efde..eb27d56bfe 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -29,6 +29,7 @@ export type { DataRouteObject, ErrorResponse, Fetcher, + FutureConfig, Hash, IndexRouteObject, IndexRouteProps, 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/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index 6ae0c0ecc5..7946c8b95c 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -2014,6 +2014,50 @@ describe("createMemoryRouter", () => { } }); + it("handles a `null` render-error", async () => { + let router = createMemoryRouter([ + { + path: "/", + Component() { + throw null; + }, + ErrorBoundary() { + return
{useRouteError() === null ? "Yes" : "No"}
; + }, + }, + ]); + let { container } = render(); + + await waitFor(() => screen.getByText("Yes")); + expect(getHtml(container)).toMatch("Yes"); + }); + + it("handles a `null` render-error from a defer() call", async () => { + let router = createMemoryRouter([ + { + path: "/", + loader() { + return defer({ lazy: Promise.reject(null) }); + }, + Component() { + let data = useLoaderData() as { lazy: Promise }; + return ( + + No + + ); + }, + ErrorBoundary() { + return
{useRouteError() === null ? "Yes" : "No"}
; + }, + }, + ]); + let { container } = render(); + + await waitFor(() => screen.getByText("Yes")); + expect(getHtml(container)).toMatch("Yes"); + }); + it("handles back button routing away from a child error boundary", async () => { let router = createMemoryRouter( createRoutesFromElements( diff --git a/packages/react-router/__tests__/useResolvedPath-test.tsx b/packages/react-router/__tests__/useResolvedPath-test.tsx index d6615e865f..a26a5c867d 100644 --- a/packages/react-router/__tests__/useResolvedPath-test.tsx +++ b/packages/react-router/__tests__/useResolvedPath-test.tsx @@ -1,7 +1,14 @@ import * as React from "react"; import * as TestRenderer from "react-test-renderer"; import type { Path } from "react-router"; -import { MemoryRouter, Routes, Route, useResolvedPath } from "react-router"; +import { + MemoryRouter, + Routes, + Route, + useResolvedPath, + useLocation, +} from "react-router"; +import { prettyDOM, render } from "@testing-library/react"; function ShowResolvedPath({ path }: { path: string | Path }) { return
{JSON.stringify(useResolvedPath(path))}
; @@ -85,6 +92,9 @@ describe("useResolvedPath", () => { }); describe("in a splat route", () => { + // Note: This test asserts long-standing buggy behavior fixed by enabling + // the future.v7_relativeSplatPath flag. See: + // https://github.com/remix-run/react-router/issues/11052#issuecomment-1836589329 it("resolves . to the route path", () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { @@ -105,5 +115,316 @@ describe("useResolvedPath", () => { `); }); + + it("resolves .. to the parent route path", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+          {"pathname":"/users","search":"","hash":""}
+        
+ `); + }); + + it("resolves . to the route path (descendant route)", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + } + /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+          {"pathname":"/users/mj","search":"","hash":""}
+        
+ `); + }); + + it("resolves .. to the parent route path (descendant route)", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + } + /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+          {"pathname":"/users","search":"","hash":""}
+        
+ `); + }); + }); + + describe("in a param route", () => { + it("resolves . to the route path", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+          {"pathname":"/users/mj","search":"","hash":""}
+        
+ `); + }); + + it("resolves .. to the parent route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+          {"pathname":"/users","search":"","hash":""}
+        
+ `); + }); + }); + + // See: https://github.com/remix-run/react-router/issues/11052#issuecomment-1836589329 + describe("future.v7_relativeSplatPath", () => { + function App({ enableFlag }: { enableFlag: boolean }) { + let routeConfigs = [ + { + routes: ( + } + /> + ), + }, + { + routes: ( + } + /> + ), + }, + { + routes: ( + + + } + /> + + ), + }, + { + routes: ( + } + /> + ), + }, + { + routes: ( + + + } + /> + + ), + }, + ]; + + return ( + <> + {routeConfigs.map((config, idx) => ( + + {config.routes} + + ))} + + ); + } + + function Component({ desc }) { + return ( + <> + {`--- Routes: ${desc} ---`} + {`useLocation(): ${useLocation().pathname}`} + {`useResolvedPath('.'): ${useResolvedPath(".").pathname}`} + {`useResolvedPath('..'): ${useResolvedPath("..").pathname}`} + {`useResolvedPath('..', { relative: 'path' }): ${ + useResolvedPath("..", { relative: "path" }).pathname + }`} + {`useResolvedPath('baz/qux'): ${useResolvedPath("baz/qux").pathname}`} + {`useResolvedPath('./baz/qux'): ${ + useResolvedPath("./baz/qux").pathname + }\n`} + + ); + } + + it("when disabled, resolves splat route relative paths differently than other routes", async () => { + let { container } = render(); + let html = getHtml(container); + html = html ? html.replace(/</g, "<").replace(/>/g, ">") : html; + expect(html).toMatchInlineSnapshot(` + "
+ --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): /foo + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): / + useResolvedPath('baz/qux'): /foo/baz/qux + useResolvedPath('./baz/qux'): /foo/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo + useResolvedPath('..'): /foo + useResolvedPath('..', { relative: 'path' }): / + useResolvedPath('baz/qux'): /foo/baz/qux + useResolvedPath('./baz/qux'): /foo/baz/qux + +
" + `); + }); + + it("when enabled, resolves splat route relative paths differently than other routes", async () => { + let { container } = render(); + let html = getHtml(container); + html = html ? html.replace(/</g, "<").replace(/>/g, ">") : html; + expect(html).toMatchInlineSnapshot(` + "
+ --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): /foo + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): /foo + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + +
" + `); + }); }); }); + +function getHtml(container: HTMLElement) { + return prettyDOM(container, undefined, { + highlight: false, + }); +} diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index d223b0a1a0..980b799a59 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -245,6 +245,22 @@ function mapRouteProperties(route: RouteObject) { }); } + if (route.HydrateFallback) { + if (__DEV__) { + if (route.hydrateFallbackElement) { + warning( + false, + "You should not include both `HydrateFallback` and `hydrateFallbackElement` on your route - " + + "`HydrateFallback` will be used." + ); + } + } + Object.assign(updates, { + hydrateFallbackElement: React.createElement(route.HydrateFallback), + HydrateFallback: undefined, + }); + } + if (route.ErrorBoundary) { if (__DEV__) { if (route.errorElement) { diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 3c4a95e2f2..918bc34d39 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -14,7 +14,7 @@ import { AbortedDeferredError, Action as NavigationType, createMemoryHistory, - UNSAFE_getPathContributingMatches as getPathContributingMatches, + UNSAFE_getResolveToMatches as getResolveToMatches, UNSAFE_invariant as invariant, parsePath, resolveTo, @@ -51,13 +51,16 @@ import { } from "./hooks"; export interface FutureConfig { + v7_relativeSplatPath: boolean; v7_startTransition: boolean; } export interface RouterProviderProps { fallbackElement?: React.ReactNode; router: RemixRouter; - future?: Partial; + // Only accept future flags relevant to rendering behavior + // routing flags should be accessed via router.future + future?: Partial>; } /** @@ -110,6 +113,16 @@ export function RouterProvider({ // pick up on any render-driven redirects/navigations (useEffect/) React.useLayoutEffect(() => router.subscribe(setState), [router, setState]); + React.useEffect(() => { + warning( + fallbackElement == null || !router.future.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 + }, []); + let navigator = React.useMemo((): Navigator => { return { createHref: router.createHref, @@ -156,9 +169,16 @@ 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 ? ( + ) : ( fallbackElement )} @@ -172,12 +192,14 @@ export function RouterProvider({ function DataRoutes({ routes, + future, state, }: { routes: DataRouteObject[]; + future: RemixRouter["future"]; state: RouterState; }): React.ReactElement | null { - return useRoutesImpl(routes, undefined, state); + return useRoutesImpl(routes, undefined, state, future); } export interface MemoryRouterProps { @@ -233,6 +255,7 @@ export function MemoryRouter({ location={state.location} navigationType={state.action} navigator={history} + future={future} /> ); } @@ -266,8 +289,10 @@ export function Navigate({ ` may be used only in the context of a component.` ); + let { future, static: isStatic } = React.useContext(NavigationContext); + warning( - !React.useContext(NavigationContext).static, + !isStatic, ` must not be used on the initial render in a . ` + `This is a no-op, but you should modify your code so the is ` + `only ever rendered in response to some user interaction or state change.` @@ -281,7 +306,7 @@ export function Navigate({ // StrictMode they navigate to the same place let path = resolveTo( to, - getPathContributingMatches(matches).map((match) => match.pathnameBase), + getResolveToMatches(matches, future.v7_relativeSplatPath), locationPathname, relative === "path" ); @@ -321,8 +346,10 @@ export interface PathRouteProps { index?: false; children?: React.ReactNode; element?: React.ReactNode | null; + hydrateFallbackElement?: React.ReactNode | null; errorElement?: React.ReactNode | null; Component?: React.ComponentType | null; + HydrateFallback?: React.ComponentType | null; ErrorBoundary?: React.ComponentType | null; } @@ -341,8 +368,10 @@ export interface IndexRouteProps { index: true; children?: undefined; element?: React.ReactNode | null; + hydrateFallbackElement?: React.ReactNode | null; errorElement?: React.ReactNode | null; Component?: React.ComponentType | null; + HydrateFallback?: React.ComponentType | null; ErrorBoundary?: React.ComponentType | null; } @@ -368,6 +397,7 @@ export interface RouterProps { navigationType?: NavigationType; navigator: Navigator; static?: boolean; + future?: Partial>; } /** @@ -386,6 +416,7 @@ export function Router({ navigationType = NavigationType.Pop, navigator, static: staticProp = false, + future, }: RouterProps): React.ReactElement | null { invariant( !useInRouterContext(), @@ -397,8 +428,16 @@ export function Router({ // the enforcement of trailing slashes throughout the app let basename = basenameProp.replace(/^\/*/, "/"); let navigationContext = React.useMemo( - () => ({ basename, navigator, static: staticProp }), - [basename, navigator, staticProp] + () => ({ + basename, + navigator, + static: staticProp, + future: { + v7_relativeSplatPath: false, + ...future, + }, + }), + [basename, future, navigator, staticProp] ); if (typeof locationProp === "string") { diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index 3e47ac2d05..a98168217a 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -28,8 +28,10 @@ export interface IndexRouteObject { index: true; children?: undefined; element?: React.ReactNode | null; + hydrateFallbackElement?: React.ReactNode | null; errorElement?: React.ReactNode | null; Component?: React.ComponentType | null; + HydrateFallback?: React.ComponentType | null; ErrorBoundary?: React.ComponentType | null; lazy?: LazyRouteFunction; } @@ -46,8 +48,10 @@ export interface NonIndexRouteObject { index?: false; children?: RouteObject[]; element?: React.ReactNode | null; + hydrateFallbackElement?: React.ReactNode | null; errorElement?: React.ReactNode | null; Component?: React.ComponentType | null; + HydrateFallback?: React.ComponentType | null; ErrorBoundary?: React.ComponentType | null; lazy?: LazyRouteFunction; } @@ -66,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; } @@ -120,6 +127,9 @@ interface NavigationContextObject { basename: string; navigator: Navigator; static: boolean; + future: { + v7_relativeSplatPath: boolean; + }; } export const NavigationContext = React.createContext( diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 390553abee..97fa226229 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -18,7 +18,7 @@ import { IDLE_BLOCKER, Action as NavigationType, UNSAFE_convertRouteMatchToUiMatch as convertRouteMatchToUiMatch, - UNSAFE_getPathContributingMatches as getPathContributingMatches, + UNSAFE_getResolveToMatches as getResolveToMatches, UNSAFE_invariant as invariant, isRouteErrorResponse, joinPaths, @@ -193,12 +193,12 @@ function useNavigateUnstable(): NavigateFunction { ); let dataRouterContext = React.useContext(DataRouterContext); - let { basename, navigator } = React.useContext(NavigationContext); + let { basename, future, navigator } = React.useContext(NavigationContext); let { matches } = React.useContext(RouteContext); let { pathname: locationPathname } = useLocation(); let routePathnamesJson = JSON.stringify( - getPathContributingMatches(matches).map((match) => match.pathnameBase) + getResolveToMatches(matches, future.v7_relativeSplatPath) ); let activeRef = React.useRef(false); @@ -309,11 +309,11 @@ export function useResolvedPath( to: To, { relative }: { relative?: RelativeRoutingType } = {} ): Path { + let { future } = React.useContext(NavigationContext); let { matches } = React.useContext(RouteContext); let { pathname: locationPathname } = useLocation(); - let routePathnamesJson = JSON.stringify( - getPathContributingMatches(matches).map((match) => match.pathnameBase) + getResolveToMatches(matches, future.v7_relativeSplatPath) ); return React.useMemo( @@ -347,7 +347,8 @@ export function useRoutes( export function useRoutesImpl( routes: RouteObject[], locationArg?: Partial | string, - dataRouterState?: RemixRouter["state"] + dataRouterState?: RemixRouter["state"], + future?: RemixRouter["future"] ): React.ReactElement | null { invariant( useInRouterContext(), @@ -469,7 +470,8 @@ export function useRoutesImpl( }) ), parentMatches, - dataRouterState + dataRouterState, + future ); // When a user passes in a `locationArg`, the associated routes need to @@ -600,7 +602,7 @@ export class RenderErrorBoundary extends React.Component< // this because the error provided from the app state may be cleared without // the location changing. return { - error: props.error || state.error, + error: props.error !== undefined ? props.error : state.error, location: state.location, revalidation: props.revalidation || state.revalidation, }; @@ -615,7 +617,7 @@ export class RenderErrorBoundary extends React.Component< } render() { - return this.state.error ? ( + return this.state.error !== undefined ? ( = 0) { + renderedMatches = renderedMatches.slice(0, fallbackIndex + 1); + } else { + renderedMatches = [renderedMatches[0]]; + } + break; + } + } + } + return renderedMatches.reduceRight((outlet, match, index) => { - let error = match.route.id ? errors?.[match.route.id] : null; - // Only data routers handle errors + // Only data routers handle errors/fallbacks + let error: any; + let shouldRenderHydrateFallback = false; let errorElement: React.ReactNode | null = null; + let hydrateFallbackElement: React.ReactNode | null = null; if (dataRouterState) { + error = errors && match.route.id ? errors[match.route.id] : undefined; errorElement = match.route.errorElement || defaultErrorElement; + + if (renderFallback) { + if (fallbackIndex < 0 && index === 0) { + warningOnce( + "route-fallback", + false, + "No `HydrateFallback` element provided to render during initial hydration" + ); + shouldRenderHydrateFallback = true; + hydrateFallbackElement = null; + } else if (fallbackIndex === index) { + shouldRenderHydrateFallback = true; + hydrateFallbackElement = match.route.hydrateFallbackElement || null; + } + } } + let matches = parentMatches.concat(renderedMatches.slice(0, index + 1)); let getChildren = () => { let children: React.ReactNode; if (error) { children = errorElement; + } else if (shouldRenderHydrateFallback) { + children = hydrateFallbackElement; } else if (match.route.Component) { // Note: This is a de-optimized path since React won't re-use the // ReactElement since it's identity changes with each new @@ -891,7 +947,7 @@ export function useRouteError(): unknown { // If this was a render error, we put it in a RouteError context inside // of RenderErrorBoundary - if (error) { + if (error !== undefined) { return error; } 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__/fetchers-test.ts b/packages/router/__tests__/fetchers-test.ts index e30ce8fff2..4073a14df6 100644 --- a/packages/router/__tests__/fetchers-test.ts +++ b/packages/router/__tests__/fetchers-test.ts @@ -122,7 +122,10 @@ describe("fetchers", () => { }); it("loader fetch", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo"); expect(A.fetcher.state).toBe("loading"); @@ -133,7 +136,10 @@ describe("fetchers", () => { }); it("loader re-fetch", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let key = "key"; let A = await t.fetch("/foo", key); @@ -153,7 +159,10 @@ describe("fetchers", () => { }); it("loader submission fetch", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "get", @@ -176,7 +185,10 @@ describe("fetchers", () => { }); it("loader submission re-fetch", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let key = "key"; let A = await t.fetch("/foo", key, { @@ -201,7 +213,12 @@ describe("fetchers", () => { }); it("action fetch", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { + loaderData: { root: "ROOT", foo: "FOO" }, + }, + }); let A = await t.fetch("/foo", { formMethod: "post", @@ -227,7 +244,10 @@ describe("fetchers", () => { }); it("action re-fetch", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let key = "key"; let A = await t.fetch("/foo", key, { @@ -717,7 +737,10 @@ describe("fetchers", () => { }); it("aborts resubmissions loader call", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let key = "KEY"; let A = await t.fetch("/foo", key, { formMethod: "post", @@ -740,7 +763,10 @@ describe("fetchers", () => { C) POST |----|---O `, () => { it("aborts A load, ignores A resolve, aborts B action", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let key = "KEY"; let A = await t.fetch("/foo", key, { @@ -759,7 +785,7 @@ describe("fetchers", () => { await A.loaders.root.resolve("A ROOT LOADER"); await A.loaders.foo.resolve("A LOADER"); - expect(t.router.state.loaderData.foo).toBeUndefined(); + expect(t.router.state.loaderData.foo).toBe("FOO"); let C = await t.fetch("/foo", key, { formMethod: "post", @@ -775,7 +801,7 @@ describe("fetchers", () => { await B.loaders.root.resolve("B ROOT LOADER"); await B.loaders.foo.resolve("B LOADER"); - expect(t.router.state.loaderData.foo).toBeUndefined(); + expect(t.router.state.loaderData.foo).toBe("FOO"); await C.loaders.root.resolve("C ROOT LOADER"); await C.loaders.foo.resolve("C LOADER"); @@ -790,7 +816,10 @@ describe("fetchers", () => { C) k1 |-----|---O `, () => { it("aborts A load, commits B and C loads", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let k1 = "1"; let k2 = "2"; @@ -815,7 +844,7 @@ describe("fetchers", () => { await Ak1.loaders.root.resolve("A ROOT LOADER"); await Ak1.loaders.foo.resolve("A LOADER"); - expect(t.router.state.loaderData.foo).toBeUndefined(); + expect(t.router.state.loaderData.foo).toBe("FOO"); await Bk2.loaders.root.resolve("B ROOT LOADER"); await Bk2.loaders.foo.resolve("B LOADER"); @@ -838,7 +867,10 @@ describe("fetchers", () => { B) POST /foo |-----[A,B]---O `, () => { it("commits A, commits B", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -871,7 +903,10 @@ describe("fetchers", () => { B) POST /foo |--X `, () => { it("catches A, persists boundary for B", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -901,7 +936,10 @@ describe("fetchers", () => { B) POST /foo |------๐Ÿงค `, () => { it("commits A, catches B", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -931,7 +969,10 @@ describe("fetchers", () => { B) POST /foo |----[A,B]--O `, () => { it("aborts A, commits B, sets A done", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -960,7 +1001,10 @@ describe("fetchers", () => { B) POST /foo |--[B]-------O `, () => { it("commits B, commits A", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -994,7 +1038,10 @@ describe("fetchers", () => { B) POST /foo |--|-----X `, () => { it("aborts B, commits A, sets B done", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", @@ -1027,7 +1074,10 @@ describe("fetchers", () => { B) nav GET |---O `, () => { it("does not abort A action or data reload", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", @@ -1129,7 +1179,10 @@ describe("fetchers", () => { B) nav GET |---O `, () => { it("commits both", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", @@ -1158,7 +1211,10 @@ describe("fetchers", () => { B) nav POST |---[A,B]--O `, () => { it("keeps both", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -1190,7 +1246,10 @@ describe("fetchers", () => { B) nav POST |-----[A,B]--O `, () => { it("aborts A, commits B, marks fetcher done", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -1218,7 +1277,10 @@ describe("fetchers", () => { B) nav POST |--[B]--O `, () => { it("commits both, uses the nav's href", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), @@ -1246,7 +1308,10 @@ describe("fetchers", () => { B) nav POST |--[B]-------X `, () => { it("aborts B, commits A, uses the nav's href", async () => { - let t = initializeTest({ url: "/foo" }); + let t = initializeTest({ + url: "/foo", + hydrationData: { loaderData: { root: "ROOT", foo: "FOO" } }, + }); let A = await t.fetch("/foo", { formMethod: "post", formData: createFormData({ key: "value" }), diff --git a/packages/router/__tests__/navigation-test.ts b/packages/router/__tests__/navigation-test.ts index fc99d9a4a7..f4bffc592e 100644 --- a/packages/router/__tests__/navigation-test.ts +++ b/packages/router/__tests__/navigation-test.ts @@ -124,6 +124,75 @@ describe("navigations", () => { }); }); + it("handles errors when unwrapping Responses", async () => { + let t = setup({ + routes: [ + { + path: "/", + children: [ + { + id: "foo", + path: "foo", + hasErrorBoundary: true, + loader: true, + }, + ], + }, + ], + }); + let A = await t.navigate("/foo"); + await A.loaders.foo.resolve( + // Invalid JSON + new Response('{"key":"value"}}}}}', { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }) + ); + expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.errors).toMatchInlineSnapshot(` + { + "foo": [SyntaxError: Unexpected token } in JSON at position 15], + } + `); + }); + + it("bubbles errors when unwrapping Responses", async () => { + let t = setup({ + routes: [ + { + id: "root", + path: "/", + hasErrorBoundary: true, + children: [ + { + id: "foo", + path: "foo", + loader: true, + }, + ], + }, + ], + }); + let A = await t.navigate("/foo"); + await A.loaders.foo.resolve( + // Invalid JSON + new Response('{"key":"value"}}}}}', { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }) + ); + expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.errors).toMatchInlineSnapshot(` + { + "root": [SyntaxError: Unexpected token } in JSON at position 15], + } + `); + }); + it("does not fetch unchanging layout data", async () => { let t = initializeTest(); let A = await t.navigate("/foo"); diff --git a/packages/router/__tests__/redirects-test.ts b/packages/router/__tests__/redirects-test.ts index d40929f821..fe949d632c 100644 --- a/packages/router/__tests__/redirects-test.ts +++ b/packages/router/__tests__/redirects-test.ts @@ -516,6 +516,7 @@ describe("redirects", () => { hydrationData: { loaderData: { action: "ACTION 0", + index: "INDEX", }, }, }); diff --git a/packages/router/__tests__/route-fallback-test.ts b/packages/router/__tests__/route-fallback-test.ts new file mode 100644 index 0000000000..b4afc25419 --- /dev/null +++ b/packages/router/__tests__/route-fallback-test.ts @@ -0,0 +1,461 @@ +import type { LoaderFunction, Router } from "../index"; +import { IDLE_NAVIGATION, createMemoryHistory, createRouter } from "../index"; + +import { + deferredData, + trackedPromise, + urlMatch, +} from "./utils/custom-matchers"; +import { createDeferred } from "./utils/data-router-setup"; +import { tick } from "./utils/utils"; + +interface CustomMatchers { + urlMatch(url: string); + trackedPromise(data?: any, error?: any, aborted?: boolean): R; + deferredData( + done: boolean, + status?: number, + headers?: Record + ): R; +} + +declare global { + namespace jest { + interface Expect extends CustomMatchers {} + interface Matchers extends CustomMatchers {} + interface InverseAsymmetricMatchers extends CustomMatchers {} + } +} + +expect.extend({ + deferredData, + trackedPromise, + urlMatch, +}); + +let router: Router; + +// Detect any failures inside the router navigate code +afterEach(() => { + router.dispose(); +}); + +describe("future.v7_partialHydration", () => { + describe("when set to false (default behavior)", () => { + it("starts with initialized=true when no loaders exist without hydrationData", async () => { + router = createRouter({ + routes: [ + { + id: "root", + path: "/", + }, + ], + history: createMemoryHistory(), + }); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + matches: [{ pathname: "/", route: { id: "root" } }], + initialized: true, + navigation: { state: "idle" }, + }); + }); + + it("starts with initialized=false when loaders exist without hydrationData", async () => { + router = createRouter({ + routes: [ + { + id: "root", + path: "/", + loader: () => Promise.resolve("LOADER DATA"), + }, + ], + history: createMemoryHistory(), + }); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: {}, + matches: [{ pathname: "/", route: { id: "root" } }], + initialized: false, + navigation: { state: "idle" }, + }); + + router.initialize(); + await tick(); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: { root: "LOADER DATA" }, + matches: [{ pathname: "/", route: { id: "root" } }], + initialized: true, + navigation: { state: "idle" }, + }); + }); + + it("starts with initialized=true when loaders exist with full hydrationData", async () => { + let spy = jest.fn(); + router = createRouter({ + routes: [ + { + id: "root", + path: "/", + loader: spy, + }, + ], + history: createMemoryHistory(), + hydrationData: { + loaderData: { root: "LOADER DATA" }, + }, + }); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: { root: "LOADER DATA" }, + matches: [{ pathname: "/", route: { id: "root" } }], + initialized: true, + navigation: { state: "idle" }, + }); + expect(spy).not.toHaveBeenCalled(); + }); + + it("starts with initialized=true when loaders exist with full hydrationData (+actions/errors)", async () => { + let spy = jest.fn(); + router = createRouter({ + routes: [ + { + id: "root", + path: "/", + hasErrorBoundary: true, + loader: spy, + action: spy, + }, + ], + history: createMemoryHistory(), + hydrationData: { + loaderData: { root: "LOADER DATA" }, + actionData: { root: "ACTION DATA" }, + errors: { root: new Error("lol") }, + }, + }); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: { root: "LOADER DATA" }, + actionData: { root: "ACTION DATA" }, + errors: { root: new Error("lol") }, + matches: [{ pathname: "/", route: { id: "root" } }], + initialized: true, + navigation: { state: "idle" }, + }); + expect(spy).not.toHaveBeenCalled(); + }); + + // This is needed because we can't detect valid "I have a loader" routes + // in Remix since all routes have a loader to fetch JS bundles but may not + // actually provide any loaderData + it("starts with initialized=true when loaders exist with partial hydration data", async () => { + let parentSpy = jest.fn(); + let childSpy = jest.fn(); + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/child"] }), + routes: [ + { + path: "/", + loader: parentSpy, + children: [ + { + path: "child", + loader: childSpy, + }, + ], + }, + ], + hydrationData: { + loaderData: { + "0": "PARENT DATA", + }, + }, + }); + router.initialize(); + + expect(parentSpy.mock.calls.length).toBe(0); + expect(childSpy.mock.calls.length).toBe(0); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: expect.objectContaining({ pathname: "/child" }), + matches: [{ route: { path: "/" } }, { route: { path: "child" } }], + initialized: true, + navigation: IDLE_NAVIGATION, + }); + expect(router.state.loaderData).toEqual({ + "0": "PARENT DATA", + }); + + router.dispose(); + }); + + it("does not kick off initial data load if errors exist", async () => { + let consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + let parentDfd = createDeferred(); + let parentSpy = jest.fn(() => parentDfd.promise); + let childDfd = createDeferred(); + let childSpy = jest.fn(() => childDfd.promise); + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/child"] }), + routes: [ + { + path: "/", + loader: parentSpy, + children: [ + { + path: "child", + loader: childSpy, + }, + ], + }, + ], + hydrationData: { + errors: { + "0": "PARENT ERROR", + }, + loaderData: { + "0-0": "CHILD_DATA", + }, + }, + }); + router.initialize(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(parentSpy).not.toHaveBeenCalled(); + expect(childSpy).not.toHaveBeenCalled(); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: expect.objectContaining({ pathname: "/child" }), + matches: [{ route: { path: "/" } }, { route: { path: "child" } }], + initialized: true, + navigation: IDLE_NAVIGATION, + errors: { + "0": "PARENT ERROR", + }, + loaderData: { + "0-0": "CHILD_DATA", + }, + }); + + router.dispose(); + consoleWarnSpy.mockReset(); + }); + }); + + describe("when set to true", () => { + 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(); + router = createRouter({ + routes: [ + { + id: "root", + path: "/", + loader: spy, + shouldRevalidate: shouldRevalidateSpy, + children: [ + { + id: "index", + index: true, + loader: () => dfd.promise, + }, + ], + }, + ], + history: createMemoryHistory(), + hydrationData: { + loaderData: { + root: "LOADER DATA", + // No loaderData provided for index route + }, + }, + future: { + v7_partialHydration: true, + }, + }); + + let subscriberSpy = jest.fn(); + router.subscribe(subscriberSpy); + + // Start with initialized:false + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: { root: "LOADER DATA" }, + initialized: false, + navigation: { state: "idle" }, + }); + + // Initialize/kick off data loads due to partial hydrationData + router.initialize(); + await dfd.resolve("INDEX DATA"); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: { root: "LOADER DATA", index: "INDEX DATA" }, + initialized: true, + navigation: { state: "idle" }, + }); + + // Root was not re-called + expect(shouldRevalidateSpy).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + + // Ensure we don't go into a navigating state during initial calls of + // the loaders + expect(subscriberSpy).toHaveBeenCalledTimes(1); + expect(subscriberSpy.mock.calls[0][0]).toMatchObject({ + loaderData: { + index: "INDEX DATA", + root: "LOADER DATA", + }, + navigation: IDLE_NAVIGATION, + }); + }); + + 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(); + let indexLoader: LoaderFunction = () => dfd.promise; + indexLoader.hydrate = true; + router = createRouter({ + routes: [ + { + id: "root", + path: "/", + loader: spy, + shouldRevalidate: shouldRevalidateSpy, + children: [ + { + id: "index", + index: true, + loader: indexLoader, + }, + ], + }, + ], + history: createMemoryHistory(), + hydrationData: { + loaderData: { + root: "LOADER DATA", + index: "INDEX INITIAL", + }, + }, + future: { + v7_partialHydration: true, + }, + }); + + let subscriberSpy = jest.fn(); + router.subscribe(subscriberSpy); + + // Start with initialized:false + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: { + root: "LOADER DATA", + index: "INDEX INITIAL", + }, + initialized: false, + navigation: { state: "idle" }, + }); + + // Initialize/kick off data loads due to partial hydrationData + router.initialize(); + await dfd.resolve("INDEX UPDATED"); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: { pathname: "/" }, + loaderData: { + root: "LOADER DATA", + index: "INDEX UPDATED", + }, + initialized: true, + navigation: { state: "idle" }, + }); + + // Root was not re-called + expect(shouldRevalidateSpy).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + + // Ensure we don't go into a navigating state during initial calls of + // the loaders + expect(subscriberSpy).toHaveBeenCalledTimes(1); + expect(subscriberSpy.mock.calls[0][0]).toMatchObject({ + loaderData: { + index: "INDEX UPDATED", + root: "LOADER DATA", + }, + navigation: IDLE_NAVIGATION, + }); + }); + + it("does not kick off initial data load if errors exist", async () => { + let consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + let parentDfd = createDeferred(); + let parentSpy = jest.fn(() => parentDfd.promise); + let childDfd = createDeferred(); + let childSpy = jest.fn(() => childDfd.promise); + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/child"] }), + routes: [ + { + path: "/", + loader: parentSpy, + children: [ + { + path: "child", + loader: childSpy, + }, + ], + }, + ], + future: { + v7_partialHydration: true, + }, + hydrationData: { + errors: { + "0": "PARENT ERROR", + }, + loaderData: { + "0-0": "CHILD_DATA", + }, + }, + }); + router.initialize(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(parentSpy).not.toHaveBeenCalled(); + expect(childSpy).not.toHaveBeenCalled(); + expect(router.state).toMatchObject({ + historyAction: "POP", + location: expect.objectContaining({ pathname: "/child" }), + matches: [{ route: { path: "/" } }, { route: { path: "child" } }], + initialized: true, + navigation: IDLE_NAVIGATION, + errors: { + "0": "PARENT ERROR", + }, + loaderData: { + "0-0": "CHILD_DATA", + }, + }); + + router.dispose(); + consoleWarnSpy.mockReset(); + }); + }); +}); diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index bbe7aeced5..a5145d0978 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -98,119 +98,6 @@ afterEach(() => { describe("a router", () => { describe("init", () => { - it("initial state w/o hydrationData", async () => { - let history = createMemoryHistory({ initialEntries: ["/"] }); - let router = createRouter({ - routes: [ - { - id: "root", - path: "/", - hasErrorBoundary: true, - loader: () => Promise.resolve(), - }, - ], - history, - }); - expect(router.state).toEqual({ - historyAction: "POP", - loaderData: {}, - actionData: null, - errors: null, - location: { - hash: "", - key: expect.any(String), - pathname: "/", - search: "", - state: null, - }, - matches: [ - { - params: {}, - pathname: "/", - pathnameBase: "/", - route: { - hasErrorBoundary: true, - id: "root", - loader: expect.any(Function), - path: "/", - }, - }, - ], - initialized: false, - navigation: { - location: undefined, - state: "idle", - }, - preventScrollReset: false, - restoreScrollPosition: null, - revalidation: "idle", - fetchers: new Map(), - blockers: new Map(), - }); - }); - - it("initial state w/hydrationData values", async () => { - let history = createMemoryHistory({ initialEntries: ["/"] }); - let router = createRouter({ - routes: [ - { - id: "root", - path: "/", - hasErrorBoundary: true, - loader: () => Promise.resolve(), - }, - ], - history, - hydrationData: { - loaderData: { root: "LOADER DATA" }, - actionData: { root: "ACTION DATA" }, - errors: { root: new Error("lol") }, - }, - }); - expect(router.state).toEqual({ - historyAction: "POP", - loaderData: { - root: "LOADER DATA", - }, - actionData: { - root: "ACTION DATA", - }, - errors: { - root: new Error("lol"), - }, - location: { - hash: "", - key: expect.any(String), - pathname: "/", - search: "", - state: null, - }, - matches: [ - { - params: {}, - pathname: "/", - pathnameBase: "/", - route: { - hasErrorBoundary: true, - id: "root", - loader: expect.any(Function), - path: "/", - }, - }, - ], - initialized: true, - navigation: { - location: undefined, - state: "idle", - }, - preventScrollReset: false, - restoreScrollPosition: false, - revalidation: "idle", - fetchers: new Map(), - blockers: new Map(), - }); - }); - it("requires routes", async () => { let history = createMemoryHistory({ initialEntries: ["/"] }); expect(() => @@ -1087,98 +974,6 @@ describe("a router", () => { router.dispose(); }); - // This is needed because we can't detect valid "I have a loader" routes - // in Remix since all routes have a loader to fetch JS bundles but may not - // actually provide any loaderData - it("treats partial hydration data as initialized", async () => { - let parentSpy = jest.fn(); - let childSpy = jest.fn(); - let router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/child"] }), - routes: [ - { - path: "/", - loader: parentSpy, - children: [ - { - path: "child", - loader: childSpy, - }, - ], - }, - ], - hydrationData: { - loaderData: { - "0": "PARENT DATA", - }, - }, - }); - router.initialize(); - - expect(parentSpy.mock.calls.length).toBe(0); - expect(childSpy.mock.calls.length).toBe(0); - expect(router.state).toMatchObject({ - historyAction: "POP", - location: expect.objectContaining({ pathname: "/child" }), - initialized: true, - navigation: IDLE_NAVIGATION, - }); - expect(router.state.loaderData).toEqual({ - "0": "PARENT DATA", - }); - - router.dispose(); - }); - - it("does not kick off initial data load due to partial hydration if errors exist", async () => { - let parentDfd = createDeferred(); - let parentSpy = jest.fn(() => parentDfd.promise); - let childDfd = createDeferred(); - let childSpy = jest.fn(() => childDfd.promise); - let router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/child"] }), - routes: [ - { - path: "/", - loader: parentSpy, - children: [ - { - path: "child", - loader: childSpy, - }, - ], - }, - ], - hydrationData: { - errors: { - "0": "PARENT ERROR", - }, - loaderData: { - "0-0": "CHILD_DATA", - }, - }, - }); - router.initialize(); - - expect(console.warn).not.toHaveBeenCalled(); - expect(parentSpy).not.toHaveBeenCalled(); - expect(childSpy).not.toHaveBeenCalled(); - expect(router.state).toMatchObject({ - historyAction: "POP", - location: expect.objectContaining({ pathname: "/child" }), - initialized: true, - navigation: IDLE_NAVIGATION, - errors: { - "0": "PARENT ERROR", - }, - loaderData: { - "0-0": "CHILD_DATA", - }, - }); - - router.dispose(); - }); - it("handles interruptions of initial data load", async () => { let parentDfd = createDeferred(); let parentSpy = jest.fn(() => parentDfd.promise); @@ -1536,6 +1331,7 @@ describe("a router", () => { hydrationData: { loaderData: { root: "ROOT_DATA", + index: "INDEX_DATA", }, }, }); @@ -1957,6 +1753,7 @@ describe("a router", () => { hydrationData: { loaderData: { root: "ROOT_DATA", + index: "INDEX_DATA", }, }, }); @@ -1997,6 +1794,7 @@ describe("a router", () => { hydrationData: { loaderData: { root: "ROOT_DATA", + index: "INDEX_DATA", }, }, }); @@ -2030,6 +1828,7 @@ describe("a router", () => { hydrationData: { loaderData: { root: "ROOT_DATA", + index: "INDEX_DATA", }, }, }); diff --git a/packages/router/index.ts b/packages/router/index.ts index cbacea8bf8..060360d34a 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -87,7 +87,7 @@ export { ErrorResponseImpl as UNSAFE_ErrorResponseImpl, convertRoutesToDataRoutes as UNSAFE_convertRoutesToDataRoutes, convertRouteMatchToUiMatch as UNSAFE_convertRouteMatchToUiMatch, - getPathContributingMatches as UNSAFE_getPathContributingMatches, + getResolveToMatches as UNSAFE_getResolveToMatches, } from "./utils"; export { 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 21453256af..8fefa26d9b 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -40,6 +40,7 @@ import { convertRouteMatchToUiMatch, convertRoutesToDataRoutes, getPathContributingMatches, + getResolveToMatches, immutableRouteKeys, isRouteErrorResponse, joinPaths, @@ -64,6 +65,14 @@ export interface Router { */ get basename(): RouterInit["basename"]; + /** + * @internal + * PRIVATE - DO NOT USE + * + * Return the future config for the router + */ + get future(): FutureConfig; + /** * @internal * PRIVATE - DO NOT USE @@ -345,7 +354,9 @@ export type HydrationState = Partial< export interface FutureConfig { v7_fetcherPersist: boolean; v7_normalizeFormMethod: boolean; + v7_partialHydration: boolean; v7_prependBasename: boolean; + v7_relativeSplatPath: boolean; } /** @@ -769,7 +780,9 @@ export function createRouter(init: RouterInit): Router { let future: FutureConfig = { v7_fetcherPersist: false, v7_normalizeFormMethod: false, + v7_partialHydration: false, v7_prependBasename: false, + v7_relativeSplatPath: false, ...init.future, }; // Cleanup function for history @@ -804,12 +817,34 @@ export function createRouter(init: RouterInit): Router { initialErrors = { [route.id]: error }; } - let initialized = + 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 = { @@ -991,7 +1026,9 @@ export function createRouter(init: RouterInit): Router { // resolved prior to router creation since we can't go into a fallbackElement // UI for SSR'd apps if (!state.initialized) { - startNavigation(HistoryAction.Pop, state.location); + startNavigation(HistoryAction.Pop, state.location, { + initialHydration: true, + }); } return router; @@ -1231,6 +1268,7 @@ export function createRouter(init: RouterInit): Router { basename, future.v7_prependBasename, to, + future.v7_relativeSplatPath, opts?.fromRouteId, opts?.relative ); @@ -1363,6 +1401,7 @@ export function createRouter(init: RouterInit): Router { historyAction: HistoryAction, location: Location, opts?: { + initialHydration?: boolean; submission?: Submission; fetcherSubmission?: Submission; overrideNavigation?: Navigation; @@ -1487,6 +1526,7 @@ export function createRouter(init: RouterInit): Router { opts && opts.submission, opts && opts.fetcherSubmission, opts && opts.replace, + opts && opts.initialHydration === true, flushSync, pendingActionData, pendingError @@ -1545,7 +1585,8 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - basename + basename, + future.v7_relativeSplatPath ); if (request.signal.aborted) { @@ -1607,6 +1648,7 @@ export function createRouter(init: RouterInit): Router { submission?: Submission, fetcherSubmission?: Submission, replace?: boolean, + initialHydration?: boolean, flushSync?: boolean, pendingActionData?: RouteData, pendingError?: RouteData @@ -1629,6 +1671,7 @@ export function createRouter(init: RouterInit): Router { matches, activeSubmission, location, + future.v7_partialHydration && initialHydration === true, isRevalidationRequired, cancelledDeferredRoutes, cancelledFetcherLoads, @@ -1674,7 +1717,12 @@ export function createRouter(init: RouterInit): Router { // state. If not, we need to switch to our loading state and load data, // preserving any new action data or existing action data (in the case of // a revalidation interrupting an actionReload) - if (!isUninterruptedRevalidation) { + // If we have partialHydration enabled, then don't update the state for the + // initial data load since iot's not a "navigation" + if ( + !isUninterruptedRevalidation && + (!future.v7_partialHydration || !initialHydration) + ) { revalidatingFetchers.forEach((rf) => { let fetcher = state.fetchers.get(rf.key); let revalidatingFetcher = getLoadingFetcher( @@ -1824,6 +1872,7 @@ export function createRouter(init: RouterInit): Router { basename, future.v7_prependBasename, href, + future.v7_relativeSplatPath, routeId, opts?.relative ); @@ -1930,7 +1979,8 @@ export function createRouter(init: RouterInit): Router { requestMatches, manifest, mapRouteProperties, - basename + basename, + future.v7_relativeSplatPath ); if (fetchRequest.signal.aborted) { @@ -2003,6 +2053,7 @@ export function createRouter(init: RouterInit): Router { matches, submission, nextLocation, + false, isRevalidationRequired, cancelledDeferredRoutes, cancelledFetcherLoads, @@ -2173,7 +2224,8 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - basename + basename, + future.v7_relativeSplatPath ); // Deferred isn't supported for fetcher loads, await everything and treat it @@ -2369,7 +2421,8 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - basename + basename, + future.v7_relativeSplatPath ) ), ...fetchersToLoad.map((f) => { @@ -2381,7 +2434,8 @@ export function createRouter(init: RouterInit): Router { f.matches, manifest, mapRouteProperties, - basename + basename, + future.v7_relativeSplatPath ); } else { let error: ErrorResult = { @@ -2723,6 +2777,9 @@ export function createRouter(init: RouterInit): Router { get basename() { return basename; }, + get future() { + return future; + }, get state() { return state; }, @@ -2764,6 +2821,13 @@ export function createRouter(init: RouterInit): Router { export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred"); +/** + * Future flags to toggle new feature behavior + */ +export interface StaticHandlerFutureConfig { + v7_relativeSplatPath: boolean; +} + export interface CreateStaticHandlerOptions { basename?: string; /** @@ -2771,6 +2835,7 @@ export interface CreateStaticHandlerOptions { */ detectErrorBoundary?: DetectErrorBoundaryFunction; mapRouteProperties?: MapRoutePropertiesFunction; + future?: Partial; } export function createStaticHandler( @@ -2796,6 +2861,11 @@ export function createStaticHandler( } else { mapRouteProperties = defaultMapRouteProperties; } + // Config driven behavior flags + let future: StaticHandlerFutureConfig = { + v7_relativeSplatPath: false, + ...(opts ? opts.future : null), + }; let dataRoutes = convertRoutesToDataRoutes( routes, @@ -3058,6 +3128,7 @@ export function createStaticHandler( manifest, mapRouteProperties, basename, + future.v7_relativeSplatPath, { isStaticRequest: true, isRouteRequest, requestContext } ); @@ -3226,6 +3297,7 @@ export function createStaticHandler( manifest, mapRouteProperties, basename, + future.v7_relativeSplatPath, { isStaticRequest: true, isRouteRequest, requestContext } ) ), @@ -3316,6 +3388,7 @@ function normalizeTo( basename: string, prependBasename: boolean, to: To | null, + v7_relativeSplatPath: boolean, fromRouteId?: string, relative?: RelativeRoutingType ) { @@ -3340,7 +3413,7 @@ function normalizeTo( // Resolve the relative path let path = resolveTo( to ? to : ".", - getPathContributingMatches(contextualMatches).map((m) => m.pathnameBase), + getResolveToMatches(contextualMatches, v7_relativeSplatPath), stripBasename(location.pathname, basename) || location.pathname, relative === "path" ); @@ -3548,6 +3621,7 @@ function getMatchesToLoad( matches: AgnosticDataRouteMatch[], submission: Submission | undefined, location: Location, + isInitialLoad: boolean, isRevalidationRequired: boolean, cancelledDeferredRoutes: string[], cancelledFetcherLoads: string[], @@ -3573,10 +3647,17 @@ function getMatchesToLoad( let boundaryMatches = getLoaderMatchesUntilBoundary(matches, boundaryId); let navigationMatches = boundaryMatches.filter((match, index) => { + if (isInitialLoad) { + // On initial hydration we don't do any shouldRevalidate stuff - we just + // call the unhydrated loaders + return isUnhydratedRoute(state, match.route); + } + if (match.route.lazy) { // We haven't loaded this route yet so we don't know if it's got a loader! return true; } + if (match.route.loader == null) { return false; } @@ -3618,8 +3699,13 @@ function getMatchesToLoad( // Pick fetcher.loads that need to be revalidated let revalidatingFetchers: RevalidatingFetcher[] = []; fetchLoadMatches.forEach((f, key) => { - // Don't revalidate if fetcher won't be present in the subsequent render + // Don't revalidate: + // - on initial load (shouldn't be any fetchers then anyway) + // - if fetcher won't be present in the subsequent render + // - no longer matches the URL (v7_fetcherPersist=false) + // - was unmounted but persisted due to v7_fetcherPersist=true if ( + isInitialLoad || !matches.some((m) => m.route.id === f.routeId) || deletedFetchers.has(key) ) { @@ -3695,6 +3781,23 @@ function getMatchesToLoad( return [navigationMatches, revalidatingFetchers]; } +// Is this route unhydrated (when v7_partialHydration=true) such that we need +// to call it's loader on the initial router creation +function isUnhydratedRoute(state: RouterState, route: AgnosticDataRouteObject) { + if (!route.loader) { + return false; + } + if (route.loader.hydrate) { + return true; + } + return ( + state.loaderData[route.id] === undefined && + (!state.errors || + // Loader ran but errored - don't re-run + state.errors[route.id] === undefined) + ); +} + function isNewLoader( currentLoaderData: RouteData, currentMatch: AgnosticDataRouteMatch, @@ -3830,6 +3933,7 @@ async function callLoaderOrAction( manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, basename: string, + v7_relativeSplatPath: boolean, opts: { isStaticRequest?: boolean; isRouteRequest?: boolean; @@ -3943,7 +4047,8 @@ async function callLoaderOrAction( matches.slice(0, matches.indexOf(match) + 1), basename, true, - location + location, + v7_relativeSplatPath ); } else if (!opts.isStaticRequest) { // Strip off the protocol+origin for same-origin + same-basename absolute @@ -3990,13 +4095,18 @@ async function callLoaderOrAction( } let data: any; - let contentType = result.headers.get("Content-Type"); - // Check between word boundaries instead of startsWith() due to the last - // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type - if (contentType && /\bapplication\/json\b/.test(contentType)) { - data = await result.json(); - } else { - data = await result.text(); + + try { + let contentType = result.headers.get("Content-Type"); + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + if (contentType && /\bapplication\/json\b/.test(contentType)) { + data = await result.json(); + } else { + data = await result.text(); + } + } catch (e) { + return { type: ResultType.error, error: e }; } if (resultType === ResultType.error) { diff --git a/packages/router/utils.ts b/packages/router/utils.ts index ccbe5537b7..bc9a4b211b 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -169,11 +169,11 @@ type DataFunctionValue = Response | NonNullable | null; /** * Route loader function signature */ -export interface LoaderFunction { +export type LoaderFunction = { (args: LoaderFunctionArgs): | Promise | DataFunctionValue; -} +} & { hydrate?: boolean }; /** * Route action function signature @@ -1145,6 +1145,25 @@ export function getPathContributingMatches< ); } +// Return the array of pathnames for the current route matches - used to +// generate the routePathnames input for resolveTo() +export function getResolveToMatches< + T extends AgnosticRouteMatch = AgnosticRouteMatch +>(matches: T[], v7_relativeSplatPath: boolean) { + let pathMatches = getPathContributingMatches(matches); + + // When v7_relativeSplatPath is enabled, use the full pathname for the leaf + // match so we include splat values for "." links. See: + // https://github.com/remix-run/react-router/issues/11052#issuecomment-1836589329 + if (v7_relativeSplatPath) { + return pathMatches.map((match, idx) => + idx === matches.length - 1 ? match.pathname : match.pathnameBase + ); + } + + return pathMatches.map((match) => match.pathnameBase); +} + /** * @private */ @@ -1191,9 +1210,12 @@ export function resolveTo( if (toPathname == null) { from = locationPathname; } else if (isPathRelative) { - let fromSegments = routePathnames[routePathnames.length - 1] - .replace(/^\//, "") - .split("/"); + let fromSegments = + routePathnames.length === 0 + ? [] + : routePathnames[routePathnames.length - 1] + .replace(/^\//, "") + .split("/"); if (toPathname.startsWith("..")) { let toSegments = toPathname.split("/");