Skip to content

Commit 22c9888

Browse files
authored
fix(react-router, solid-router) - useParams should only be concerned about the relevant params (#5097)
#5020 highlighted an issue where when using useParams in parent route (that has its own path param) with child routes that also use path params this can cause excessive re-rendering of the parent route when the params for the child routes changes. The root cause for this is that the child params are available to the parent route, even when strict=false was not specified and this also does not match to the types that is being returned in this case. This can be worked around by using select inside the useParams hook/function but results in unnecessary boilerplate. This PR attempts to resolve the issue by dealing with the two distinct cases, one where we are only concerned about the path params of the current route and prior routes (strict=true/undefined) and the second where we want access in the parent route to the path params of the child routes (strict=false). In the strict case we use the match id to return the params of the specific match, which is constant and results in stable rendering, while in the strict=false case we fallback to the initial implementation of returning params from the useMatch result which can be variable and requires re-rendering to be done. Changes were done on useParams for both react and solid, in solid's case this wasn't causing re-renders but did return unexpected child params, the change for solid is more toward keeping the API and expected outputs consistent between the two frameworks. existing unit tests for react was updated and e2e tests for both frameworks was added to ensure rendering and results match the expected. A separate merge has been prepared for Alpha and will be merged on signoff of this one. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added nested named-parameter routes (three-level: foo → bar → baz) in example apps with nested navigation, outlets, links and render-count displays. - Added a small render-counter component to visualize component re-renders. - **Bug Fixes** - Improved params selection to respect strict vs non-strict modes, reducing excess renders and producing stable param values. - **Tests** - Added E2E tests for React and Solid examples covering param navigation and render-counts; expanded router unit tests for nested-route stability and remount behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6202162 commit 22c9888

File tree

18 files changed

+705
-52
lines changed

18 files changed

+705
-52
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { useRef } from 'react'
2+
3+
export const RenderCounter = () => {
4+
const renderCounter = useRef(0)
5+
renderCounter.current = renderCounter.current + 1
6+
return <>{renderCounter.current}</>
7+
}

e2e/react-router/basic-file-based/src/routeTree.gen.ts

Lines changed: 87 additions & 21 deletions
Large diffs are not rendered by default.

e2e/react-router/basic-file-based/src/routes/params-ps/named/$foo.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
3+
export const Route = createFileRoute('/params-ps/named/$foo/$bar/$baz')({
4+
component: RouteComponent,
5+
})
6+
7+
function RouteComponent() {
8+
const { foo, bar, baz } = Route.useParams()
9+
return (
10+
<div>
11+
Hello "/params-ps/named/{foo}/{bar}/{baz}"!
12+
<div>
13+
baz: <span data-testid="foo-bar-baz-value">{baz}</span>
14+
</div>
15+
</div>
16+
)
17+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
Link,
3+
Outlet,
4+
createFileRoute,
5+
useParams,
6+
} from '@tanstack/react-router'
7+
import { RenderCounter } from '../../../../components/RenderCounter'
8+
9+
export const Route = createFileRoute('/params-ps/named/$foo/$bar')({
10+
component: RouteComponent,
11+
})
12+
13+
function RouteComponent() {
14+
const { foo, bar, baz } = useParams({
15+
strict: false,
16+
})
17+
18+
return (
19+
<div>
20+
Hello "/params-ps/named/{foo}/{bar}"!
21+
<div>
22+
Bar Render Count:{' '}
23+
<span data-testid="foo-bar-render-count">
24+
<RenderCounter />
25+
</span>
26+
</div>
27+
<div>
28+
Bar: <span data-testid="foo-bar-value">{bar}</span>
29+
</div>
30+
<div>
31+
Baz in Bar:{' '}
32+
<span data-testid="foo-baz-in-bar-value">{baz ?? 'no param'}</span>
33+
</div>
34+
<Link
35+
data-testid="params-foo-bar-links-baz"
36+
from={Route.fullPath}
37+
to="./$baz"
38+
params={{ baz: `${bar}_10` }}
39+
>
40+
To Baz
41+
</Link>
42+
<Outlet />
43+
</div>
44+
)
45+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
2+
import { RenderCounter } from '../../../../components/RenderCounter'
3+
4+
export const Route = createFileRoute('/params-ps/named/$foo')({
5+
component: RouteComponent,
6+
})
7+
8+
function RouteComponent() {
9+
const foo = Route.useParams()
10+
return (
11+
<div>
12+
<h3>ParamsNamedFoo</h3>
13+
<div>
14+
RenderCount:{' '}
15+
<span data-testid="foo-render-count">
16+
<RenderCounter />
17+
</span>
18+
</div>
19+
<div data-testid="params-output">{JSON.stringify(foo)}</div>
20+
<Link from={Route.fullPath} to="." data-testid="params-foo-links-index">
21+
Index
22+
</Link>
23+
<Link
24+
from={Route.fullPath}
25+
to="./$bar"
26+
params={{ bar: '1' }}
27+
data-testid="params-foo-links-bar1"
28+
>
29+
Bar1
30+
</Link>
31+
<Link
32+
from={Route.fullPath}
33+
to="./$bar"
34+
params={{ bar: '2' }}
35+
data-testid="params-foo-links-bar2"
36+
>
37+
Bar2
38+
</Link>
39+
<Outlet />
40+
</div>
41+
)
42+
}

e2e/react-router/basic-file-based/src/routes/params-ps/named/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { createFileRoute } from '@tanstack/react-router'
2-
import { redirect } from '@tanstack/react-router'
1+
import { createFileRoute, redirect } from '@tanstack/react-router'
32

43
export const Route = createFileRoute('/params-ps/named/')({
54
beforeLoad: () => {

e2e/react-router/basic-file-based/tests/params.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,80 @@ test.describe('params operations + prefix/suffix', () => {
129129
expect(paramsObj).toEqual(params)
130130
})
131131
})
132+
133+
test(`ensure use params doesn't cause excess renders and is stable across various usage options`, async ({
134+
page,
135+
}) => {
136+
await page.goto('/params-ps/named/foo')
137+
await page.waitForLoadState('networkidle')
138+
139+
const pagePathname = new URL(page.url()).pathname
140+
expect(pagePathname).toBe('/params-ps/named/foo')
141+
142+
const fooRenderCount = page.getByTestId('foo-render-count')
143+
const fooIndexLink = page.getByTestId('params-foo-links-index')
144+
const fooBar1Link = page.getByTestId('params-foo-links-bar1')
145+
const fooBar2Link = page.getByTestId('params-foo-links-bar2')
146+
const fooBarBazLink = page.getByTestId('params-foo-bar-links-baz')
147+
const fooValue = page.getByTestId('params-output')
148+
const fooBarValue = page.getByTestId('foo-bar-value')
149+
const fooBazInBarValue = page.getByTestId('foo-baz-in-bar-value')
150+
const fooBarRenderCount = page.getByTestId('foo-bar-render-count')
151+
const fooBarBazValue = page.getByTestId('foo-bar-baz-value')
152+
153+
await expect(fooRenderCount).toBeInViewport()
154+
await expect(fooValue).toBeInViewport()
155+
await expect(fooIndexLink).toBeInViewport()
156+
await expect(fooBar1Link).toBeInViewport()
157+
await expect(fooBar2Link).toBeInViewport()
158+
await expect(fooRenderCount).toHaveText('1')
159+
await expect(fooValue).toHaveText(JSON.stringify({ foo: 'foo' }))
160+
161+
await fooBar1Link.click()
162+
await page.waitForLoadState('networkidle')
163+
await expect(fooValue).toBeInViewport()
164+
await expect(fooRenderCount).toBeInViewport()
165+
await expect(fooBarRenderCount).toBeInViewport()
166+
await expect(fooBarValue).toBeInViewport()
167+
await expect(fooBazInBarValue).toBeInViewport()
168+
await expect(fooBarBazLink).toBeInViewport()
169+
await expect(fooValue).toHaveText(JSON.stringify({ foo: 'foo' }))
170+
await expect(fooRenderCount).toHaveText('1')
171+
await expect(fooBarRenderCount).toHaveText('1')
172+
await expect(fooBarValue).toHaveText('1')
173+
await expect(fooBazInBarValue).toHaveText('no param')
174+
175+
await fooBarBazLink.click()
176+
await page.waitForLoadState('networkidle')
177+
await expect(fooValue).toBeInViewport()
178+
await expect(fooRenderCount).toBeInViewport()
179+
await expect(fooBarRenderCount).toBeInViewport()
180+
await expect(fooBarValue).toBeInViewport()
181+
await expect(fooBazInBarValue).toBeInViewport()
182+
await expect(fooValue).toHaveText(JSON.stringify({ foo: 'foo' }))
183+
await expect(fooRenderCount).toHaveText('1')
184+
await expect(fooBarRenderCount).toHaveText('2')
185+
await expect(fooBarValue).toHaveText('1')
186+
await expect(fooBazInBarValue).toHaveText('1_10')
187+
await expect(fooBarBazValue).toHaveText('1_10')
188+
189+
await fooBar2Link.click()
190+
await page.waitForLoadState('networkidle')
191+
await expect(fooValue).toBeInViewport()
192+
await expect(fooRenderCount).toBeInViewport()
193+
await expect(fooBarValue).toBeInViewport()
194+
await expect(fooValue).toHaveText(JSON.stringify({ foo: 'foo' }))
195+
await expect(fooRenderCount).toHaveText('1')
196+
await expect(fooBarValue).toHaveText('2')
197+
198+
await fooIndexLink.click()
199+
await page.waitForLoadState('networkidle')
200+
await expect(fooValue).toBeInViewport()
201+
await expect(fooRenderCount).toBeInViewport()
202+
await expect(fooBarValue).not.toBeInViewport()
203+
await expect(fooValue).toHaveText(JSON.stringify({ foo: 'foo' }))
204+
await expect(fooRenderCount).toHaveText('1')
205+
})
132206
})
133207

134208
test.describe('wildcard param', () => {

0 commit comments

Comments
 (0)