Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-router): add support for structural sharing for finegrained selectors #2647

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
13 changes: 6 additions & 7 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ export const MatchInner = React.memo(function MatchInnerImpl({
return {
routeId,
matchIndex,
match: pick(match, ['id', 'status', 'error', 'loadPromise']),
match: pick(match, ['id', 'status', 'error']),
}
},
structuralSharing: true as any,
})

const route = router.routesById[routeId]!
Expand Down Expand Up @@ -180,7 +181,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
// false,
// 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!',
// )
throw match.loadPromise
throw router.getMatch(match.id)?.loadPromise
}

if (match.status === 'error') {
Expand Down Expand Up @@ -237,7 +238,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}, pendingMinMs)
}
}
throw match.loadPromise
throw router.getMatch(match.id)?.loadPromise
}

return (
Expand All @@ -259,17 +260,15 @@ export const Outlet = React.memo(function OutletImpl() {

const route = router.routesById[routeId]!

const { parentGlobalNotFound } = useRouterState({
const parentGlobalNotFound = useRouterState({
select: (s) => {
const matches = s.matches
const parentMatch = matches.find((d) => d.id === matchId)
invariant(
parentMatch,
`Could not find parent match for matchId "${matchId}"`,
)
return {
parentGlobalNotFound: parentMatch.globalNotFound,
}
return parentMatch.globalNotFound
},
})

Expand Down
67 changes: 43 additions & 24 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { Transitioner } from './Transitioner'
import { matchContext } from './matchContext'
import { Match } from './Match'
import { SafeFragment } from './SafeFragment'
import type { StructuralSharingOption } from './structuralSharing'
import type { AnyRoute, ReactNode, StaticDataRouteOption } from './route'
import type { AnyRouter, RegisteredRouter } from './router'
import type { AnyRouter, RegisteredRouter, RouterState } from './router'
import type { ResolveRelativePath, ToOptions } from './link'
import type {
AllContext,
Expand Down Expand Up @@ -288,6 +289,7 @@ export function useMatchRoute<TRouter extends AnyRouter = RegisteredRouter>() {

useRouterState({
select: (s) => [s.location.href, s.resolvedLocation.href, s.status],
structuralSharing: true as any,
})

return React.useCallback(
Expand Down Expand Up @@ -369,56 +371,73 @@ export type MakeRouteMatchUnion<
>
: never

export interface UseMatchesBaseOptions<TRouter extends AnyRouter, TSelected> {
select?: (matches: Array<MakeRouteMatchUnion<TRouter>>) => TSelected
}

export type UseMatchesResult<
TRouter extends AnyRouter,
TSelected,
> = unknown extends TSelected ? Array<MakeRouteMatchUnion<TRouter>> : TSelected

export function useMatches<
TRouter extends AnyRouter = RegisteredRouter,
TRouteMatch = MakeRouteMatchUnion<TRouter>,
T = Array<TRouteMatch>,
>(opts?: { select?: (matches: Array<TRouteMatch>) => T }): T {
TSelected = unknown,
TStructuralSharing extends boolean = boolean,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected> &
StructuralSharingOption<TRouter, TSelected, TStructuralSharing>,
): UseMatchesResult<TRouter, TSelected> {
return useRouterState({
select: (state) => {
select: (state: RouterState<TRouter['routeTree']>) => {
const matches = state.matches
return opts?.select
? opts.select(matches as Array<TRouteMatch>)
: (matches as T)
? opts.select(matches as Array<MakeRouteMatchUnion<TRouter>>)
: matches
},
})
structuralSharing: opts?.structuralSharing,
} as any) as UseMatchesResult<TRouter, TSelected>
}

export function useParentMatches<
TRouter extends AnyRouter = RegisteredRouter,
TRouteMatch = MakeRouteMatchUnion<TRouter>,
T = Array<TRouteMatch>,
>(opts?: { select?: (matches: Array<TRouteMatch>) => T }): T {
TSelected = unknown,
TStructuralSharing extends boolean = boolean,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected> &
StructuralSharingOption<TRouter, TSelected, TStructuralSharing>,
): UseMatchesResult<TRouter, TSelected> {
const contextMatchId = React.useContext(matchContext)

return useMatches({
select: (matches) => {
select: (matches: Array<MakeRouteMatchUnion<TRouter>>) => {
matches = matches.slice(
0,
matches.findIndex((d) => d.id === contextMatchId),
)
return opts?.select
? opts.select(matches as Array<TRouteMatch>)
: (matches as T)
return opts?.select ? opts.select(matches) : matches
},
})
structuralSharing: opts?.structuralSharing,
} as any)
}

export function useChildMatches<
TRouter extends AnyRouter = RegisteredRouter,
TRouteMatch = MakeRouteMatchUnion<TRouter>,
T = Array<TRouteMatch>,
>(opts?: { select?: (matches: Array<TRouteMatch>) => T }): T {
TSelected = unknown,
TStructuralSharing extends boolean = boolean,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected> &
StructuralSharingOption<TRouter, TSelected, TStructuralSharing>,
): UseMatchesResult<TRouter, TSelected> {
const contextMatchId = React.useContext(matchContext)

return useMatches({
select: (matches) => {
select: (matches: Array<MakeRouteMatchUnion<TRouter>>) => {
matches = matches.slice(
matches.findIndex((d) => d.id === contextMatchId) + 1,
)
return opts?.select
? opts.select(matches as Array<TRouteMatch>)
: (matches as T)
return opts?.select ? opts.select(matches) : matches
},
})
structuralSharing: opts?.structuralSharing,
} as any)
}
5 changes: 4 additions & 1 deletion packages/react-router/src/RouterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,21 @@ export type RouterProps<
RouterOptions<
TRouter['routeTree'],
NonNullable<TRouter['options']['trailingSlash']>,
NonNullable<TRouter['options']['defaultStructuralSharing']>,
TDehydrated
>,
'context'
> & {
router: Router<
TRouter['routeTree'],
NonNullable<TRouter['options']['trailingSlash']>
NonNullable<TRouter['options']['trailingSlash']>,
NonNullable<TRouter['options']['defaultStructuralSharing']>
>
context?: Partial<
RouterOptions<
TRouter['routeTree'],
NonNullable<TRouter['options']['trailingSlash']>,
NonNullable<TRouter['options']['defaultStructuralSharing']>,
TDehydrated
>['context']
>
Expand Down
12 changes: 11 additions & 1 deletion packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,25 @@ import { trimPathRight } from './path'
export function Transitioner() {
const router = useRouter()
const mountLoadForRouter = React.useRef({ router, mounted: false })
// CHECK HERE
const routerState = useRouterState({
select: (s) =>
pick(s, ['isLoading', 'location', 'resolvedLocation', 'isTransitioning']),
pick(s, [
'isLoading',
'location',
'resolvedLocation',
'isTransitioning',
'matches',
]),
structuralSharing: true as any,
})

const [isTransitioning, startReactTransition_] = React.useTransition()
// Track pending state changes
// CHECK HERE
const hasPendingMatches = useRouterState({
select: (s) => s.matches.some((d) => d.status === 'pending'),
structuralSharing: true,
})

const previousIsLoading = usePrevious(routerState.isLoading)
Expand Down
58 changes: 29 additions & 29 deletions packages/react-router/src/fileRoute.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import warning from 'tiny-warning'
import { createRoute } from './route'

import { useMatch } from './useMatch'
import { useLoaderDeps } from './useLoaderDeps'
import { useLoaderData } from './useLoaderData'
import { useSearch } from './useSearch'
import { useParams } from './useParams'
import { useNavigate } from './useNavigate'
import type { UseParamsRoute } from './useParams'
import type { UseMatchRoute } from './useMatch'
import type { UseSearchRoute } from './useSearch'
import type { Constrain } from './utils'
import type {
AnyContext,
Expand All @@ -20,9 +24,11 @@ import type {
RouteLoaderFn,
UpdatableRouteOptions,
} from './route'
import type { MakeRouteMatch } from './Matches'
import type { RegisteredRouter } from './router'
import type { RouteById, RouteIds } from './routeInfo'
import type { UseLoaderDepsRoute } from './useLoaderDeps'
import type { UseLoaderDataRoute } from './useLoaderData'
import type { UseRouteContextRoute } from './useRouteContext'

export interface FileRoutesByPath {
// '/': {
Expand Down Expand Up @@ -208,48 +214,42 @@ export class LazyRoute<TRoute extends AnyRoute> {
;(this as any).$$typeof = Symbol.for('react.memo')
}

useMatch = <
TRouteMatch = MakeRouteMatch<
RegisteredRouter['routeTree'],
TRoute['types']['id']
>,
TSelected = TRouteMatch,
>(opts?: {
select?: (match: TRouteMatch) => TSelected
}): TSelected => {
return useMatch({ select: opts?.select, from: this.options.id })
useMatch: UseMatchRoute<TRoute['id']> = (opts) => {
return useMatch({
select: opts?.select,
from: this.options.id,
structuralSharing: opts?.structuralSharing,
} as any) as any
}

useRouteContext = <TSelected = TRoute['types']['allContext']>(opts?: {
select?: (s: TRoute['types']['allContext']) => TSelected
}): TSelected => {
useRouteContext: UseRouteContextRoute<TRoute['id']> = (opts) => {
return useMatch({
from: this.options.id,
select: (d: any) => (opts?.select ? opts.select(d.context) : d.context),
})
}) as any
}

useSearch = <TSelected = TRoute['types']['fullSearchSchema']>(opts?: {
select?: (s: TRoute['types']['fullSearchSchema']) => TSelected
}): TSelected => {
return useSearch({ ...opts, from: this.options.id })
useSearch: UseSearchRoute<TRoute['id']> = (opts) => {
return useSearch({
select: opts?.select,
structuralSharing: opts?.structuralSharing,
from: this.options.id,
} as any)
}

useParams = <TSelected = TRoute['types']['allParams']>(opts?: {
select?: (s: TRoute['types']['allParams']) => TSelected
}): TSelected => {
return useParams({ ...opts, from: this.options.id })
useParams: UseParamsRoute<TRoute['id']> = (opts) => {
return useParams({
select: opts?.select,
structuralSharing: opts?.structuralSharing,
from: this.options.id,
} as any)
}

useLoaderDeps = <TSelected = TRoute['types']['loaderDeps']>(opts?: {
select?: (s: TRoute['types']['loaderDeps']) => TSelected
}): TSelected => {
useLoaderDeps: UseLoaderDepsRoute<TRoute['id']> = (opts) => {
return useLoaderDeps({ ...opts, from: this.options.id } as any)
}

useLoaderData = <TSelected = TRoute['types']['loaderData']>(opts?: {
select?: (s: TRoute['types']['loaderData']) => TSelected
}): TSelected => {
useLoaderData: UseLoaderDataRoute<TRoute['id']> = (opts) => {
return useLoaderData({ ...opts, from: this.options.id } as any)
}

Expand Down
9 changes: 6 additions & 3 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,10 @@ export interface MaskOptions<
}

export type ToMaskOptions<
TRouteTree extends AnyRouter = RegisteredRouter,
TRouter extends AnyRouter = RegisteredRouter,
TMaskFrom extends string = string,
TMaskTo extends string = '.',
> = ToSubOptions<TRouteTree, TMaskFrom, TMaskTo> & {
> = ToSubOptions<TRouter, TMaskFrom, TMaskTo> & {
unmaskOnReload?: boolean
}

Expand Down Expand Up @@ -645,7 +645,10 @@ export function useLinkProps<
}, [to])

// subscribe to search params to re-build location if it changes
const currentSearch = useRouterState({ select: (s) => s.location.search })
const currentSearch = useRouterState({
select: (s) => s.location.search,
structuralSharing: true as any,
})

const next = React.useMemo(
() => router.buildLocation(options as any),
Expand Down
Loading