Skip to content

Commit

Permalink
feat(react-router): add remountDeps
Browse files Browse the repository at this point in the history
  • Loading branch information
schiller-manuel committed Jan 28, 2025
1 parent 2c904b8 commit c9f8f74
Show file tree
Hide file tree
Showing 13 changed files with 427 additions and 60 deletions.
2 changes: 1 addition & 1 deletion docs/eslint/create-route-property-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The correct property order is as follows
- `context`
- `beforeLoad`
- `loader`
- `onEnter`, `onStay`, `onLeave`, `meta`, `links`, `scripts`, `headers`
- `onEnter`, `onStay`, `onLeave`, `meta`, `links`, `scripts`, `headers`, `remountDeps`

All other properties are insensitive to the order as they do not depend on type inference.

Expand Down
32 changes: 32 additions & 0 deletions docs/framework/react/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,35 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record<string, any>
- Type: `(error: Error, errorInfo: ErrorInfo) => void`
- Optional - Defaults to `routerOptions.defaultOnCatch`
- A function that will be called when errors are caught when the route encounters an error.
### `remountDeps` method
- Type:
```tsx
type remountDeps = (opts: RemountDepsOptions) => any

interface RemountDepsOptions<
in out TRouteId,
in out TFullSearchSchema,
in out TAllParams,
in out TLoaderDeps,
> {
routeId: TRouteId
search: TFullSearchSchema
params: TAllParams
loaderDeps: TLoaderDeps
}
```

- Optional
- A function that will be called to determine whether a route component shall be remounted after navigation. If this function returns a different value than previously, it will remount.
- The return value needs to be JSON serializable.
- By default, a route component will not be remounted if it stays active after a navigation

Example:
If you want to configure to remount a route component upon `params` change, use:

```tsx
remountDeps: ({ params }) => params
```
32 changes: 32 additions & 0 deletions docs/framework/react/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,35 @@ const router = createRouter({
- Defaults to `false`
- Configures whether structural sharing is enabled by default for fine-grained selectors.
- See the [Render Optimizations guide](../../guide/render-optimizations.md) for more information.

### `defaultRemountDeps` property

- Type:

```tsx
type defaultRemountDeps = (opts: RemountDepsOptions) => any

interface RemountDepsOptions<
in out TRouteId,
in out TFullSearchSchema,
in out TAllParams,
in out TLoaderDeps,
> {
routeId: TRouteId
search: TFullSearchSchema
params: TAllParams
loaderDeps: TLoaderDeps
}
```

- Optional
- A default function that will be called to determine whether a route component shall be remounted after navigation. If this function returns a different value than previously, it will remount.
- The return value needs to be JSON serializable.
- By default, a route component will not be remounted if it stays active after a navigation

Example:
If you want to configure to remount all route components upon `params` change, use:

```tsx
remountDeps: ({ params }) => params
```
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,16 @@ export const sortRules = [
[['beforeLoad'], ['loader']],
[
['loader'],
['onEnter', 'onStay', 'onLeave', 'meta', 'links', 'scripts', 'headers'],
[
'onEnter',
'onStay',
'onLeave',
'meta',
'links',
'scripts',
'headers',
'remountDeps',
],
],
] as const

Expand Down
28 changes: 22 additions & 6 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,26 +114,41 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}): any {
const router = useRouter()

const { match, matchIndex, routeId } = useRouterState({
const { match, key, routeId } = useRouterState({
select: (s) => {
const matchIndex = s.matches.findIndex((d) => d.id === matchId)
const match = s.matches[matchIndex]!
const routeId = match.routeId as string

const remountFn =
(router.routesById[routeId] as AnyRoute).options.remountDeps ??
router.options.defaultRemountDeps
const remountDeps = remountFn?.({
routeId,
loaderDeps: match.loaderDeps,
params: match._strictParams,
search: match._strictSearch,
})
const key = remountDeps ? JSON.stringify(remountDeps) : undefined

return {
key,
routeId,
matchIndex,
match: pick(match, ['id', 'status', 'error']),
}
},
structuralSharing: true as any,
})

const route = router.routesById[routeId]!
const route = router.routesById[routeId] as AnyRoute

const out = React.useMemo(() => {
const Comp = route.options.component ?? router.options.defaultComponent
return Comp ? <Comp /> : <Outlet />
}, [route.options.component, router.options.defaultComponent])
if (Comp) {
return <Comp key={key} />
}
return <Outlet />
}, [key, route.options.component, router.options.defaultComponent])

// function useChangedDiff(value: any) {
// const ref = React.useRef(value)
Expand Down Expand Up @@ -184,7 +199,8 @@ export const MatchInner = React.memo(function MatchInnerImpl({
if (router.isServer) {
return (
<RouteErrorComponent
error={match.error}
error={match.error as any}
reset={undefined as any}
info={{
componentStack: '',
}}
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface RouteMatch<
index: number
pathname: string
params: TAllParams
_strictParams: TAllParams
status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound'
isFetching: false | 'beforeLoad' | 'loader'
error: unknown
Expand All @@ -77,6 +78,7 @@ export interface RouteMatch<
__beforeLoadContext: Record<string, unknown>
context: TAllContext
search: TFullSearchSchema
_strictSearch: TFullSearchSchema
fetchCount: number
abortController: AbortController
cause: 'preload' | 'enter' | 'stay'
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ export type {
BeforeLoadContextParameter,
ResolveAllContext,
ResolveAllParamsFromParent,
MakeRemountDepsOptionsUnion,
RemountDepsOptions,
} from './route'

export type {
Expand Down
39 changes: 38 additions & 1 deletion packages/react-router/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import type {
RouteMatch,
} from './Matches'
import type { NavigateOptions, ToMaskOptions } from './link'
import type { RouteById, RouteIds, RoutePaths } from './routeInfo'
import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo'
import type { AnyRouter, RegisteredRouter, Router } from './router'
import type { BuildLocationFn, NavigateFn } from './RouterProvider'
import type { NotFoundError } from './not-found'
Expand Down Expand Up @@ -154,6 +154,7 @@ export type FileBaseRouteOptions<
TRouterContext = {},
TRouteContextFn = AnyContext,
TBeforeLoadFn = AnyContext,
TRemountDepsFn = AnyContext,
> = ParamsOptions<TPath, TParams> & {
validateSearch?: Constrain<TSearchValidator, AnyValidator, DefaultValidator>

Expand Down Expand Up @@ -204,6 +205,18 @@ export type FileBaseRouteOptions<
opts: FullSearchSchemaOption<TParentRoute, TSearchValidator>,
) => TLoaderDeps

remountDeps?: Constrain<
TRemountDepsFn,
(
opt: RemountDepsOptions<
TId,
FullSearchSchemaOption<TParentRoute, TSearchValidator>,
Expand<ResolveAllParamsFromParent<TParentRoute, TParams>>,
TLoaderDeps
>,
) => any
>

loader?: Constrain<
TLoaderFn,
(
Expand Down Expand Up @@ -275,6 +288,30 @@ export interface RouteContextOptions<
context: Expand<RouteContextParameter<TParentRoute, TRouterContext>>
}

export interface RemountDepsOptions<
in out TRouteId,
in out TFullSearchSchema,
in out TAllParams,
in out TLoaderDeps,
> {
routeId: TRouteId
search: TFullSearchSchema
params: TAllParams
loaderDeps: TLoaderDeps
}

export type MakeRemountDepsOptionsUnion<
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
TRoute extends AnyRoute = ParseRoute<TRouteTree>,
> = TRoute extends any
? RemountDepsOptions<
TRoute['id'],
TRoute['types']['fullSearchSchema'],
TRoute['types']['allParams'],
TRoute['types']['loaderDeps']
>
: never

export interface BeforeLoadContextOptions<
in out TParentRoute extends AnyRoute,
in out TSearchValidator,
Expand Down
43 changes: 32 additions & 11 deletions packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import type {
BeforeLoadContextOptions,
ErrorRouteComponent,
LoaderFnContext,
MakeRemountDepsOptionsUnion,
NotFoundRouteComponent,
RootRoute,
RouteComponent,
Expand Down Expand Up @@ -451,6 +452,8 @@ export interface RouterOptions<
pathParamsAllowedCharacters?: Array<
';' | ':' | '@' | '&' | '=' | '+' | '$' | ','
>

defaultRemountDeps?: (opts: MakeRemountDepsOptionsUnion<TRouteTree>) => any
}

export interface RouterErrorSerializer<TSerializedError> {
Expand Down Expand Up @@ -1151,19 +1154,26 @@ export class Router<

const parentMatch = matches[index - 1]

const [preMatchSearch, searchError]: [Record<string, any>, any] = (() => {
const [preMatchSearch, strictMatchSearch, searchError]: [
Record<string, any>,
Record<string, any>,
any,
] = (() => {
// Validate the search params and stabilize them
const parentSearch = parentMatch?.search ?? next.search
const parentStrictSearch = parentMatch?._strictSearch ?? {}

try {
const search =
validateSearch(route.options.validateSearch, parentSearch) ?? {}
const strictSearch =
validateSearch(route.options.validateSearch, { ...parentSearch }) ??
{}

return [
{
...parentSearch,
...search,
...strictSearch,
},
{ ...parentStrictSearch, ...strictSearch },
undefined,
]
} catch (err: any) {
Expand All @@ -1178,7 +1188,7 @@ export class Router<
throw searchParamError
}

return [parentSearch, searchParamError]
return [parentSearch, {}, searchParamError]
}
})()

Expand All @@ -1194,7 +1204,7 @@ export class Router<

const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : ''

const interpolatedPath = interpolatePath({
const { usedParams, interpolatedPath } = interpolatePath({
path: route.fullPath,
params: routeParams,
decodeCharMap: this.pathParamsDecodeCharMap,
Expand All @@ -1206,7 +1216,7 @@ export class Router<
params: routeParams,
leaveWildcards: true,
decodeCharMap: this.pathParamsDecodeCharMap,
}) + loaderDepsHash
}).interpolatedPath + loaderDepsHash

// Waste not, want not. If we already have a match for this route,
// reuse it. This is important for layout routes, which might stick
Expand All @@ -1231,9 +1241,11 @@ export class Router<
params: previousMatch
? replaceEqualDeep(previousMatch.params, routeParams)
: routeParams,
_strictParams: usedParams,
search: previousMatch
? replaceEqualDeep(previousMatch.search, preMatchSearch)
: replaceEqualDeep(existingMatch.search, preMatchSearch),
_strictSearch: strictMatchSearch,
}
} else {
const status =
Expand All @@ -1251,11 +1263,13 @@ export class Router<
params: previousMatch
? replaceEqualDeep(previousMatch.params, routeParams)
: routeParams,
_strictParams: usedParams,
pathname: joinPaths([this.basepath, interpolatedPath]),
updatedAt: Date.now(),
search: previousMatch
? replaceEqualDeep(previousMatch.search, preMatchSearch)
: preMatchSearch,
_strictSearch: strictMatchSearch,
searchError: undefined,
status,
isFetching: false,
Expand Down Expand Up @@ -1463,7 +1477,7 @@ export class Router<
path: route.fullPath,
params: matchedRoutesResult?.routeParams ?? {},
decodeCharMap: this.pathParamsDecodeCharMap,
})
}).interpolatedPath
const pathname = joinPaths([this.basepath, interpolatedPath])
return pathname === fromPath
})?.id as keyof this['routesById']
Expand Down Expand Up @@ -1503,7 +1517,7 @@ export class Router<
leaveWildcards: false,
leaveParams: opts.leaveParams,
decodeCharMap: this.pathParamsDecodeCharMap,
})
}).interpolatedPath

let search = fromSearch
if (opts._includeValidateSearch && this.options.search?.strict) {
Expand Down Expand Up @@ -2173,6 +2187,10 @@ export class Router<
this._handleNotFound(matches, err, {
updateMatch,
})
this.serverSsr?.onMatchSettled({
router: this,
match: this.getMatch(match.id)!,
})
throw err
}
}
Expand Down Expand Up @@ -2637,6 +2655,7 @@ export class Router<
if (isNotFound(err) && !allPreload) {
await triggerOnReady()
}

throw err
}
}
Expand Down Expand Up @@ -2835,8 +2854,10 @@ export class Router<
_fromLocation: next,
})
}
// Preload errors are not fatal, but we should still log them
console.error(err)
if (!isNotFound(err)) {
// Preload errors are not fatal, but we should still log them
console.error(err)
}
return undefined
}
}
Expand Down
Loading

0 comments on commit c9f8f74

Please sign in to comment.