diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index fefd256bf6..5de5546aeb 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -20,7 +20,25 @@ import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' import { useLayoutEffect } from './utils' -import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' +import type { + AnyRoute, + AnyRouteMatch, + ParsedLocation, + RootRouteOptions, +} from '@tanstack/router-core' + +type OutletMatchSelection = [ + routeId: string | undefined, + parentGlobalNotFound: boolean, +] + +const matchViewFieldsEqual = (a: AnyRouteMatch, b: AnyRouteMatch) => + a.routeId === b.routeId && a._displayPending === b._displayPending + +const outletMatchSelectionEqual = ( + a: OutletMatchSelection, + b: OutletMatchSelection, +) => a[0] === b[0] && a[1] === b[1] export const Match = React.memo(function MatchImpl({ matchId, @@ -77,7 +95,7 @@ export const Match = React.memo(function MatchImpl({ // eslint-disable-next-line react-hooks/rules-of-hooks const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt) // eslint-disable-next-line react-hooks/rules-of-hooks - const match = useStore(matchStore, (value) => value) + const match = useStore(matchStore, (value) => value, matchViewFieldsEqual) // eslint-disable-next-line react-hooks/rules-of-hooks const matchState = React.useMemo(() => { const routeId = match.routeId as string @@ -208,7 +226,7 @@ function MatchView({ {matchState.parentRouteId === rootRouteId ? ( <> - + {router.options.scrollRestoration && (isServer ?? router.isServer) ? ( ) : null} @@ -222,34 +240,49 @@ function MatchView({ // the route subtree has committed below the root layout. Keeping it here lets // us fire onRendered even after a hydration mismatch above the root layout // (like bad head/link tags, which is common). -function OnRendered({ resetKey }: { resetKey: number }) { +function OnRendered() { const router = useRouter() if (isServer ?? router.isServer) { return null } + // Track the resolvedLocation as of the last render so that onRendered can + // report the correct fromLocation. By the time this effect fires, + // resolvedLocation has already been updated to the new location by + // Transitioner, so we cannot use router.stores.resolvedLocation.get() + // directly as the fromLocation. + // @ts-expect-error -- init to `undefined` but don't write `undefined` to shave bytes + // eslint-disable-next-line react-hooks/rules-of-hooks + const prevResolvedLocationRef = React.useRef< + ParsedLocation | undefined + >() // eslint-disable-next-line react-hooks/rules-of-hooks - const prevHrefRef = React.useRef(undefined) + const renderedLocationKey = useStore( + router.stores.resolvedLocation, + (resolvedLocation) => resolvedLocation?.state.__TSR_key, + ) // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { - const currentHref = router.latestLocation.href + const currentResolvedLocation = router.stores.resolvedLocation.get() + const previousResolvedLocation = prevResolvedLocationRef.current if ( - prevHrefRef.current === undefined || - prevHrefRef.current !== currentHref + currentResolvedLocation && + (!previousResolvedLocation || + previousResolvedLocation.href !== currentResolvedLocation.href) ) { router.emit({ type: 'onRendered', ...getLocationChangeInfo( router.stores.location.get(), - router.stores.resolvedLocation.get(), + previousResolvedLocation ?? currentResolvedLocation, ), }) - prevHrefRef.current = currentHref } - }, [router.latestLocation.state.__TSR_key, resetKey, router]) + prevResolvedLocationRef.current = currentResolvedLocation + }, [renderedLocationKey, router]) return null } @@ -521,10 +554,14 @@ export const Outlet = React.memo(function OutletImpl() { : undefined // eslint-disable-next-line react-hooks/rules-of-hooks - ;[routeId, parentGlobalNotFound] = useStore(parentMatchStore, (match) => [ - match?.routeId as string | undefined, - match?.globalNotFound ?? false, - ]) + ;[routeId, parentGlobalNotFound] = useStore( + parentMatchStore, + (match): OutletMatchSelection => [ + match?.routeId as string | undefined, + match?.globalNotFound ?? false, + ], + outletMatchSelectionEqual, + ) // eslint-disable-next-line react-hooks/rules-of-hooks childMatchId = useStore(router.stores.matchesId, (ids) => {