Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions packages/react-router/src/useBlocker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,22 +179,24 @@ export function useBlocker(
location: HistoryLocation,
): AnyShouldBlockFnLocation {
const parsedLocation = router.parseLocation(location)
const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname)
if (matchedRoutes.foundRoute === undefined) {
const [, rawParams, foundRoute] = router.getMatchedRoutes(
parsedLocation.pathname,
)
if (foundRoute === undefined) {
return {
routeId: '__notFound__',
fullPath: parsedLocation.pathname,
pathname: parsedLocation.pathname,
params: matchedRoutes.routeParams,
params: rawParams,
search: router.options.parseSearch(location.search),
}
}

return {
routeId: matchedRoutes.foundRoute.id,
fullPath: matchedRoutes.foundRoute.fullPath,
routeId: foundRoute.id,
fullPath: foundRoute.fullPath,
pathname: parsedLocation.pathname,
params: matchedRoutes.routeParams,
params: rawParams,
search: router.options.parseSearch(location.search),
}
}
Expand Down
107 changes: 107 additions & 0 deletions packages/react-router/tests/issue-7964-param-parsing-loader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, test } from 'vitest'
import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from '../src'

afterEach(() => {
cleanup()
})

// https://github.com/TanStack/router/issues/7964
test('#7964: a child loader receives fresh structured params after revisiting its route', async () => {
const rootRoute = createRootRoute({
component: Outlet,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>Home page</div>,
})
const parsedParamRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/$parsedParam',
params: {
parse: ({ parsedParam }) => {
const [recordName, revisionNumber] = parsedParam.split('-')
return {
parsedParam: {
recordName: recordName!,
revisionNumber: Number(revisionNumber),
},
}
},
stringify: ({ parsedParam }) => ({
parsedParam: `${parsedParam.recordName}-${parsedParam.revisionNumber}`,
}),
},
component: Outlet,
})
const parsedParamIndexRoute = createRoute({
getParentRoute: () => parsedParamRoute,
path: '/',
loader: ({ params }) => params.parsedParam.revisionNumber * 100,
component: () => {
const params = parsedParamIndexRoute.useParams()
const loaderData = parsedParamIndexRoute.useLoaderData()

return (
<>
<div data-testid="params">
Params have {params.parsedParam.recordName} with revision{' '}
{params.parsedParam.revisionNumber}
</div>
<div data-testid="loader-data">Loader data is {loaderData}</div>
</>
)
},
})
const routeTree = rootRoute.addChildren([
indexRoute,
parsedParamRoute.addChildren([parsedParamIndexRoute]),
])
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(<RouterProvider router={router} />)
expect(await screen.findByText('Home page')).toBeInTheDocument()

await act(() =>
router.navigate({
to: '/$parsedParam',
params: { parsedParam: { recordName: 'blue', revisionNumber: 1 } },
}),
)
expect(
await screen.findByText('Params have blue with revision 1'),
).toBeInTheDocument()
expect(await screen.findByText('Loader data is 100')).toBeInTheDocument()

await act(() => router.navigate({ to: '/' }))
expect(await screen.findByText('Home page')).toBeInTheDocument()

await act(() =>
router.navigate({
to: '/$parsedParam',
params: { parsedParam: { recordName: 'red', revisionNumber: 2 } },
}),
)
expect(router.state.location.pathname).toBe('/red-2')

await waitFor(() => {
expect({
params: screen.getByTestId('params').textContent,
loaderData: screen.getByTestId('loader-data').textContent,
}).toEqual({
params: 'Params have red with revision 2',
loaderData: 'Loader data is 200',
})
})
})
75 changes: 36 additions & 39 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,13 +750,12 @@ export type ParseLocationFn<TRouteTree extends AnyRoute> = (
previousLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>,
) => ParsedLocation<FullSearchSchema<TRouteTree>>

export type GetMatchRoutesFn = (pathname: string) => {
matchedRoutes: ReadonlyArray<AnyRoute>
export type GetMatchRoutesFn = (pathname: string) => [
matchedRoutes: ReadonlyArray<AnyRoute>,
/** exhaustive params, still in their string form */
routeParams: Record<string, string>
foundRoute: AnyRoute | undefined
parseError?: unknown
}
rawParams: Record<string, string>,
foundRoute: AnyRoute | undefined,
]

export type EmitFn = (routerEvent: RouterEvent) => void

Expand Down Expand Up @@ -1512,16 +1511,17 @@ export class RouterCore<
next: ParsedLocation,
opts?: MatchRoutesOpts,
): Array<AnyRouteMatch> {
const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
const { foundRoute, routeParams } = matchedRoutesResult
let { matchedRoutes } = matchedRoutesResult
const [initialMatchedRoutes, rawParams, foundRoute] = this.getMatchedRoutes(
next.pathname,
)
let matchedRoutes = initialMatchedRoutes
let isGlobalNotFound = false

// Check to see if the route needs a 404 entry
if (
// If we found a route, and it's not an index route and we have left over path
foundRoute
? foundRoute.path !== '/' && routeParams['**']
? foundRoute.path !== '/' && rawParams['**']
: // Or if we didn't find a route and we have left over path
trimPathRight(next.pathname)
) {
Expand Down Expand Up @@ -1549,6 +1549,7 @@ export class RouterCore<
: undefined
}

let strictParams: AnyRouteMatch['_strictParams'] | undefined
for (let index = 0; index < matchedRoutes.length; index++) {
const route = matchedRoutes[index]!
// Take each matched route and resolve + validate its search params
Expand Down Expand Up @@ -1614,9 +1615,10 @@ export class RouterCore<
}
searchError ??= cause
}
// Match identity must only use the raw params captured from the URL.
const { interpolatedPath, usedParams } = interpolatePath({
path: route.fullPath,
params: routeParams,
params: rawParams,
decoder: this.pathParamsDecoder,
server: this.isServer,
})
Expand All @@ -1639,7 +1641,9 @@ export class RouterCore<
: (this._cache.get(matchId) ??
(previousMatch?.id === matchId ? previousMatch : undefined))

const strictParams = existingMatch?._strictParams ?? usedParams
// Carry parsed ancestors forward without mutating the raw route params.
strictParams =
existingMatch?._strictParams ?? Object.assign(usedParams, strictParams)

let paramsError: unknown

Expand All @@ -1661,8 +1665,6 @@ export class RouterCore<
}
}

Object.assign(routeParams, strictParams)

const cause = previousMatch ? 'stay' : 'enter'

let match: AnyRouteMatch
Expand All @@ -1671,8 +1673,6 @@ export class RouterCore<
match = {
...existingMatch,
cause,
params: previousMatch?.params ?? routeParams,
_strictParams: strictParams,
search: previousMatch
? nullReplaceEqualDeep(previousMatch.search, preMatchSearch)
: nullReplaceEqualDeep(existingMatch.search, preMatchSearch),
Expand All @@ -1687,7 +1687,7 @@ export class RouterCore<
ssr: (isServer ?? this.isServer) ? undefined : route.options.ssr,
index,
routeId: route.id,
params: previousMatch?.params ?? routeParams,
params: previousMatch?.params ?? strictParams,
_strictParams: strictParams,
pathname: interpolatedPath,
updatedAt: Date.now(),
Expand Down Expand Up @@ -1727,8 +1727,8 @@ export class RouterCore<
const match = matches[index]!
match.params =
match.cause === 'stay'
? nullReplaceEqualDeep(match.params, routeParams)
: routeParams
? nullReplaceEqualDeep(match.params, strictParams)
: strictParams!
if (opts?._controller) {
match.context = {}
}
Expand All @@ -1738,20 +1738,20 @@ export class RouterCore<
}

getMatchedRoutes: GetMatchRoutesFn = (pathname) => {
const routeParams: Record<string, string> = Object.create(null)
const rawParams: Record<string, string> = Object.create(null)
const match = findRouteMatch(
trimPathRight(pathname),
this.processedTree,
true,
)
if (match) {
Object.assign(routeParams, match.rawParams)
}
return {
matchedRoutes: match?.branch || [this.routesById[rootRouteId]!],
routeParams,
foundRoute: match?.route,
Object.assign(rawParams, match.rawParams)
}
return [
match?.branch || [this.routesById[rootRouteId]!],
rawParams,
match?.route,
]
}

/**
Expand All @@ -1772,9 +1772,7 @@ export class RouterCore<
return cached[1 /* result */]
}

const { matchedRoutes, routeParams } = this.getMatchedRoutes(
location.pathname,
)
const [matchedRoutes, rawParams] = this.getMatchedRoutes(location.pathname)
const lastRoute = last(matchedRoutes)!

// I don't know if we should run the full search middleware chain, or just validateSearch
Expand Down Expand Up @@ -1812,7 +1810,7 @@ export class RouterCore<
// Parse params through the route chain
const strictParams: Record<string, unknown> = Object.assign(
Object.create(null),
routeParams,
rawParams,
)
for (const route of matchedRoutes) {
try {
Expand Down Expand Up @@ -1862,7 +1860,7 @@ export class RouterCore<
process.env.NODE_ENV !== 'production' &&
dest._isNavigate
) {
const allFromMatches = this.getMatchedRoutes(dest.from).matchedRoutes
const [allFromMatches] = this.getMatchedRoutes(dest.from)

const matchedFrom = findLast(lightweightResult.matchedRoutes, (d) => {
return comparePaths(d.fullPath, dest.from!)
Expand Down Expand Up @@ -1918,14 +1916,13 @@ export class RouterCore<
// typed destination mismatch, not a concrete URL to route-match.
destRoutes = []
} else {
const destMatchResult = this.getMatchedRoutes(nextTo)
destRoutes = destMatchResult.matchedRoutes
const [matchedRoutes, rawParams, foundRoute] =
this.getMatchedRoutes(nextTo)
destRoutes = matchedRoutes

if (
this.options.notFoundRoute &&
(!destMatchResult.foundRoute ||
(destMatchResult.foundRoute.path !== '/' &&
destMatchResult.routeParams['**']))
(!foundRoute || (foundRoute.path !== '/' && rawParams['**']))
) {
destRoutes = [...destRoutes, this.options.notFoundRoute]
}
Expand Down Expand Up @@ -1968,10 +1965,10 @@ export class RouterCore<
!opts.leaveParams
) {
try {
const roundTrip = this.getMatchedRoutes(nextPathname)
if (roundTrip.foundRoute?.id !== destRoute.id) {
const foundRoute = this.getMatchedRoutes(nextPathname)[2]
if (foundRoute?.id !== destRoute.id) {
console.warn(
`Generated path "${nextPathname}" for route "${destRoute.id}" matched route "${roundTrip.foundRoute?.id}" instead. This can happen when multiple route templates resolve to the same URL. Use the route template that matches the intended route, or adjust params.stringify if it changed the target path.`,
`Generated path "${nextPathname}" for route "${destRoute.id}" matched route "${foundRoute?.id}" instead. This can happen when multiple route templates resolve to the same URL. Use the route template that matches the intended route, or adjust params.stringify if it changed the target path.`,
)
}
} catch {
Expand Down
6 changes: 2 additions & 4 deletions packages/router-plugin/tests/handle-route-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ describe('handleRouteUpdate', () => {
const routeTree = rootRoute.addChildren([asAnyRoute(itemRoute)])
const router = createTestRouter(routeTree)

expect(router.getMatchedRoutes('/items/abc').foundRoute?.id).toBeUndefined()
expect(router.getMatchedRoutes('/items/abc')[2]?.id).toBeUndefined()

const restoreWindow = withWindowRouter(router)
try {
Expand All @@ -258,9 +258,7 @@ describe('handleRouteUpdate', () => {
restoreWindow()
}

expect(router.getMatchedRoutes('/items/abc').foundRoute?.id).toBe(
itemRoute.id,
)
expect(router.getMatchedRoutes('/items/abc')[2]?.id).toBe(itemRoute.id)
})

it('hydrates the hot module route export with generated route tree state', () => {
Expand Down
14 changes: 8 additions & 6 deletions packages/solid-router/src/useBlocker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,21 +186,23 @@ export function useBlocker(
location: HistoryLocation,
): AnyShouldBlockFnLocation {
const parsedLocation = router.parseLocation(location)
const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname)
if (matchedRoutes.foundRoute === undefined) {
const [, rawParams, foundRoute] = router.getMatchedRoutes(
parsedLocation.pathname,
)
if (foundRoute === undefined) {
return {
routeId: '__notFound__',
fullPath: parsedLocation.pathname,
pathname: parsedLocation.pathname,
params: matchedRoutes.routeParams,
params: rawParams,
search: parsedLocation.search,
}
}
return {
routeId: matchedRoutes.foundRoute.id,
fullPath: matchedRoutes.foundRoute.fullPath,
routeId: foundRoute.id,
fullPath: foundRoute.fullPath,
pathname: parsedLocation.pathname,
params: matchedRoutes.routeParams,
params: rawParams,
search: parsedLocation.search,
}
}
Expand Down
Loading
Loading