Skip to content

Commit 149ad65

Browse files
authored
Add future.v7_relativeSplatPath flag (#11087)
* Add future.v7_relativeSplatPath flag This commit started with an initial revert of commit 9cfd99d * Add StaticRouter future flag * Add changeset * Bump bundle * Add example of useResolvedPath relative:path with the flag enabled * Add future flag to docs
1 parent 839df23 commit 149ad65

File tree

20 files changed

+661
-41
lines changed

20 files changed

+661
-41
lines changed

.changeset/relative-splat-path.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
"react-router-dom-v5-compat": minor
3+
"react-router-native": minor
4+
"react-router-dom": minor
5+
"react-router": minor
6+
"@remix-run/router": minor
7+
---
8+
9+
Add a new `future.v7_relativeSplatPath` flag to implenent a breaking bug fix to relative routing when inside a splat route.
10+
11+
This fix was originally added in [#10983](https://github.com/remix-run/react-router/issues/10983) and was later reverted in [#11078](https://github.com/remix-run/react-router/issues/110788) because it was determined that a large number of existing applications were relying on the buggy behavior (see [#11052](https://github.com/remix-run/react-router/issues/11052))
12+
13+
**The Bug**
14+
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.
15+
16+
**The Background**
17+
This decision was originally made thinking that it would make the concept of nested different sections of your apps in `<Routes>` easier if relative routing would _replace_ the current splat:
18+
19+
```jsx
20+
<BrowserRouter>
21+
<Routes>
22+
<Route path="/" element={<Home />} />
23+
<Route path="dashboard/*" element={<Dashboard />} />
24+
</Routes>
25+
</BrowserRouter>
26+
```
27+
28+
Any paths like `/dashboard`, `/dashboard/team`, `/dashboard/projects` will match the `Dashboard` route. The dashboard component itself can then render nested `<Routes>`:
29+
30+
```jsx
31+
function Dashboard() {
32+
return (
33+
<div>
34+
<h2>Dashboard</h2>
35+
<nav>
36+
<Link to="/">Dashboard Home</Link>
37+
<Link to="team">Team</Link>
38+
<Link to="projects">Projects</Link>
39+
</nav>
40+
41+
<Routes>
42+
<Route path="/" element={<DashboardHome />} />
43+
<Route path="team" element={<DashboardTeam />} />
44+
<Route path="projects" element={<DashboardProjects />} />
45+
</Routes>
46+
</div>
47+
);
48+
}
49+
```
50+
51+
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.
52+
53+
**The Problem**
54+
55+
The problem is that this concept of ignoring part of a pth breaks a lot of other assumptions in React Router - namely that `"."` always means the current location pathname for that route. When we ignore the splat portion, we start getting invalid paths when using `"."`:
56+
57+
```jsx
58+
// If we are on URL /dashboard/team, and we want to link to /dashboard/team:
59+
function DashboardTeam() {
60+
// ❌ This is broken and results in <a href="/dashboard">
61+
return <Link to=".">A broken link to the Current URL</Link>;
62+
63+
// ✅ This is fixed but super unintuitive since we're already at /dashboard/team!
64+
return <Link to="./team">A broken link to the Current URL</Link>;
65+
}
66+
```
67+
68+
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.
69+
70+
Even worse, consider a nested splat route configuration:
71+
72+
```jsx
73+
<BrowserRouter>
74+
<Routes>
75+
<Route path="dashboard">
76+
<Route path="*" element={<Dashboard />} />
77+
</Route>
78+
</Routes>
79+
</BrowserRouter>
80+
```
81+
82+
Now, a `<Link to=".">` and a `<Link to="..">` inside the `Dashboard` component go to the same place! That is definitely not correct!
83+
84+
Another common issue arose in Data Routers (and Remix) where any `<Form>` should post to it's own route `action` if you the user doesn't specify a form action:
85+
86+
```jsx
87+
let router = createBrowserRouter({
88+
path: "/dashboard",
89+
children: [
90+
{
91+
path: "*",
92+
action: dashboardAction,
93+
Component() {
94+
// ❌ This form is broken! It throws a 405 error when it submits because
95+
// it tries to submit to /dashboard (without the splat value) and the parent
96+
// `/dashboard` route doesn't have an action
97+
return <Form method="post">...</Form>;
98+
},
99+
},
100+
],
101+
});
102+
```
103+
104+
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.
105+
106+
**The Solution**
107+
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:
108+
109+
```jsx
110+
<BrowserRouter>
111+
<Routes>
112+
<Route path="dashboard">
113+
<Route path="*" element={<Dashboard />} />
114+
</Route>
115+
</Routes>
116+
</BrowserRouter>
117+
118+
function Dashboard() {
119+
return (
120+
<div>
121+
<h2>Dashboard</h2>
122+
<nav>
123+
<Link to="..">Dashboard Home</Link>
124+
<Link to="../team">Team</Link>
125+
<Link to="../projects">Projects</Link>
126+
</nav>
127+
128+
<Routes>
129+
<Route path="/" element={<DashboardHome />} />
130+
<Route path="team" element={<DashboardTeam />} />
131+
<Route path="projects" element={<DashboardProjects />} />
132+
</Router>
133+
</div>
134+
);
135+
}
136+
```
137+
138+
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".

docs/components/form.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ If you need to post to a different route, then add an action prop:
109109

110110
- [Index Search Param][indexsearchparam] (index vs parent route disambiguation)
111111

112+
<docs-info>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</docs-info>
113+
112114
## `method`
113115

114116
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`
394396
[history-state]: https://developer.mozilla.org/en-US/docs/Web/API/History/state
395397
[use-view-transition-state]: ../hooks//use-view-transition-state
396398
[view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
399+
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths

docs/components/link.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ A relative `<Link to>` value (that does not begin with `/`) resolves relative to
6363

6464
<docs-info>`<Link to>` with a `..` behaves differently from a normal `<a href>` when the current URL ends with `/`. `<Link to>` ignores the trailing slash, and removes one URL segment for each `..`. But an `<a href>` value handles `..` differently when the current URL ends with `/` vs when it does not.</docs-info>
6565

66+
<docs-info>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 `<Link to>` behavior within splat routes</docs-info>
67+
6668
## `relative`
6769

6870
By default, links are relative to the route hierarchy (`relative="route"`), so `..` will go up one `Route` level. 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. You can opt into this behavior with `relative="path"`:
@@ -201,3 +203,4 @@ function ImageLink(to) {
201203
[view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
202204
[picking-a-router]: ../routers/picking-a-router
203205
[navlink]: ./nav-link
206+
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths

docs/guides/api-development-strategy.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ const router = createBrowserRouter(routes, {
6363
});
6464
```
6565

66-
| Flag | Description |
67-
| ----------------------------------------- | --------------------------------------------------------------------- |
68-
| `v7_fetcherPersist` | Delay active fetcher cleanup until they return to an `idle` state |
69-
| `v7_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method |
70-
| [`v7_partialHydration`][partialhydration] | Support partial hydration for Server-rendered apps |
71-
| `v7_prependBasename` | Prepend the router basename to navigate/fetch paths |
66+
| Flag | Description |
67+
| ------------------------------------------- | --------------------------------------------------------------------- |
68+
| `v7_fetcherPersist` | Delay active fetcher cleanup until they return to an `idle` state |
69+
| `v7_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method |
70+
| [`v7_partialHydration`][partialhydration] | Support partial hydration for Server-rendered apps |
71+
| `v7_prependBasename` | Prepend the router basename to navigate/fetch paths |
72+
| [`v7_relativeSplatPath`][relativesplatpath] | Fix buggy relative path resolution in splat routes |
7273

7374
### React Router Future Flags
7475

@@ -96,3 +97,4 @@ These flags apply to both Data and non-Data Routers and are passed to the render
9697
[picking-a-router]: ../routers/picking-a-router
9798
[starttransition]: https://react.dev/reference/react/startTransition
9899
[partialhydration]: ../routers/create-browser-router#partial-hydration-data
100+
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths

docs/hooks/use-href.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ declare function useHref(
1818

1919
The `useHref` hook returns a URL that may be used to link to the given `to` location, even outside of React Router.
2020

21-
> **Tip:**
22-
>
23-
> You may be interested in taking a look at the source for the `<Link>`
24-
> component in `react-router-dom` to see how it uses `useHref` internally to
25-
> determine its own `href` value.
21+
<docs-info>You may be interested in taking a look at the source for the `<Link>` component in `react-router-dom` to see how it uses `useHref` internally to determine its own `href` value</docs-info>
22+
23+
<docs-info>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</docs-info>
24+
25+
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths

docs/hooks/use-navigate.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ The `navigate` function has two signatures:
5454
- Either pass a `To` value (same type as `<Link to>`) with an optional second `options` argument (similar to the props you can pass to [`<Link>`][link]), or
5555
- Pass the delta you want to go in the history stack. For example, `navigate(-1)` is equivalent to hitting the back button
5656

57+
<docs-info>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</docs-info>
58+
5759
## `options.replace`
5860

5961
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
119121
[picking-a-router]: ../routers/picking-a-router
120122
[flush-sync]: https://react.dev/reference/react-dom/flushSync
121123
[start-transition]: https://react.dev/reference/react/startTransition
124+
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths

docs/hooks/use-resolved-path.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,64 @@ This is useful when building links from relative values. For example, check out
2222

2323
See [resolvePath][resolvepath] for more information.
2424

25+
## Splat Paths
26+
27+
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.
28+
29+
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:
30+
31+
- `<Link to>`
32+
- `useNavigate()`
33+
- `useHref()`
34+
- `<Form action>`
35+
- `useSubmit()`
36+
- Relative path `redirect` responses returned from loaders and actions
37+
38+
### Behavior without the flag
39+
40+
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:
41+
42+
```jsx
43+
<BrowserRouter>
44+
<Routes>
45+
<Route path="/dashboard/*" element={<Dashboard />} />
46+
</Routes>
47+
</BrowserRouter>
48+
```
49+
50+
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_.
51+
52+
This makes for a slight convenience in routing between "sibling" splat routes (`/dashboard/teams`, `/dashboard/projects`, etc.), however it causes other inconsistencies such as:
53+
54+
- `useResolvedPath(".")` no longer resolves to the current location for that route, it actually resolved you "up" to `/dashboard` from `/dashboard/teams`
55+
- If you changed your route definition to use a dynamic parameter (`<Route path="/dashboard/:widget">`), then any resolved paths inside the `Dashboard` component would break since the dynamic param value is not ignored like the splat value
56+
57+
And then it gets worse if you define the splat route as a child:
58+
59+
```jsx
60+
<BrowserRouter>
61+
<Routes>
62+
<Route path="/dashboard">
63+
<Route path="*" element={<Dashboard />} />
64+
</Route>
65+
</Routes>
66+
</BrowserRouter>
67+
```
68+
69+
- Now, `useResolvedPath(".")` and `useResolvedPath("..")` resolve to the exact same path inside `<Dashboard />`
70+
- If you were using a Data Router and defined an `action` on the splat route, you'd get a 405 error on `<Form>` submissions because they (by default) submit to `"."` which would resolve to the parent `/dashboard` route which doesn't have an `action`.
71+
72+
### Behavior with the flag
73+
74+
When you enable the flag, this "bug" is fixed so that path resolution is consistent across all route types, `useResolvedPath(".")` always resolves to the current pathname for the contextual route. This includes any dynamic param or splat param values.
75+
76+
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 (`<Route path="*" element={<Dashboard />} />`) and use `useResolvedPath("../teams")` and `useResolvedPath("../projects")` parent-relative paths to navigate to sibling routes.
77+
2578
[navlink]: ../components/nav-link
2679
[resolvepath]: ../utils/resolve-path
80+
[release-6.19.0]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v6190
81+
[release-6.20.1]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v6201
82+
[release-6.21.0]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v6210
83+
[revert-comment]: https://github.com/remix-run/react-router/issues/11052#issuecomment-1836589329
84+
[future-flag]: ../guides/api-development-strategy
85+
[splat]: ../route/route#splats

docs/hooks/use-submit.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ submit(null, {
150150
<Form action="/logout" method="post" />;
151151
```
152152

153+
<docs-info>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</docs-info>
154+
153155
Because submissions are navigations, the options may also contain the other navigation related props from [`<Form>`][form] such as:
154156

155157
- `fetcherKey`
@@ -170,3 +172,4 @@ The `unstable_flushSync` option tells React Router DOM to wrap the initial state
170172
[form]: ../components/form
171173
[flush-sync]: https://react.dev/reference/react-dom/flushSync
172174
[start-transition]: https://react.dev/reference/react/startTransition
175+
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,19 +110,19 @@
110110
},
111111
"filesize": {
112112
"packages/router/dist/router.umd.min.js": {
113-
"none": "49.8 kB"
113+
"none": "50.1 kB"
114114
},
115115
"packages/react-router/dist/react-router.production.min.js": {
116-
"none": "14.5 kB"
116+
"none": "14.6 kB"
117117
},
118118
"packages/react-router/dist/umd/react-router.production.min.js": {
119-
"none": "16.9 kB"
119+
"none": "17.0 kB"
120120
},
121121
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
122-
"none": "16.7 kB"
122+
"none": "16.8 kB"
123123
},
124124
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
125-
"none": "22.9 kB"
125+
"none": "23.0 kB"
126126
}
127127
}
128128
}

packages/react-router-dom-v5-compat/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export type {
5959
FormEncType,
6060
FormMethod,
6161
FormProps,
62+
FutureConfig,
6263
GetScrollRestorationKeyFunction,
6364
Hash,
6465
HashRouterProps,

packages/react-router-dom/index.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export type {
104104
DataRouteObject,
105105
ErrorResponse,
106106
Fetcher,
107+
FutureConfig,
107108
Hash,
108109
IndexRouteObject,
109110
IndexRouteProps,
@@ -666,6 +667,9 @@ export function RouterProvider({
666667
navigator,
667668
static: false,
668669
basename,
670+
future: {
671+
v7_relativeSplatPath: router.future.v7_relativeSplatPath,
672+
},
669673
}),
670674
[router, navigator, basename]
671675
);
@@ -764,6 +768,7 @@ export function BrowserRouter({
764768
location={state.location}
765769
navigationType={state.action}
766770
navigator={history}
771+
future={future}
767772
/>
768773
);
769774
}
@@ -814,6 +819,7 @@ export function HashRouter({
814819
location={state.location}
815820
navigationType={state.action}
816821
navigator={history}
822+
future={future}
817823
/>
818824
);
819825
}
@@ -860,6 +866,7 @@ function HistoryRouter({
860866
location={state.location}
861867
navigationType={state.action}
862868
navigator={history}
869+
future={future}
863870
/>
864871
);
865872
}
@@ -1558,10 +1565,8 @@ export function useFormAction(
15581565
// object referenced by useMemo inside useResolvedPath
15591566
let path = { ...useResolvedPath(action ? action : ".", { relative }) };
15601567

1561-
// Previously we set the default action to ".". The problem with this is that
1562-
// `useResolvedPath(".")` excludes search params of the resolved URL. This is
1563-
// the intended behavior of when "." is specifically provided as
1564-
// the form action, but inconsistent w/ browsers when the action is omitted.
1568+
// If no action was specified, browsers will persist current search params
1569+
// when determining the path, so match that behavior
15651570
// https://github.com/remix-run/remix/issues/927
15661571
let location = useLocation();
15671572
if (action == null) {

0 commit comments

Comments
 (0)