Skip to content
Draft
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
65 changes: 41 additions & 24 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
AnyRouteMatch,
ParsedLocation,
RootRouteOptions,
RouterReadableStore,
} from '@tanstack/router-core'

type OutletMatchSelection = [
Expand Down Expand Up @@ -66,6 +67,24 @@ const getLoadPromise = (
return promise
}

export function PendingFallback({
matchStore,
pendingMinMs,
component: PendingComponent,
}: {
matchStore: RouterReadableStore<AnyRouteMatch | undefined>
pendingMinMs: number | undefined
component: React.ComponentType
}) {
const localPromise = useStore(matchStore, (match) =>
match?.status === 'pending' ? match._.loadPromise : undefined,
)
if (localPromise?.status === 'pending' && pendingMinMs) {
localPromise.pendingUntil ??= Date.now() + pendingMinMs
}
return <PendingComponent />
}

export const Match = React.memo(function MatchImpl({
matchId,
}: {
Expand Down Expand Up @@ -140,7 +159,15 @@ function MatchView({
const PendingComponent =
route.options.pendingComponent ?? router.options.defaultPendingComponent

const pendingElement = PendingComponent ? <PendingComponent /> : null
const pendingElement = PendingComponent ? (
<PendingFallback
matchStore={router.stores.matchStores.get(matchId)!}
pendingMinMs={
route.options.pendingMinMs ?? router.options.defaultPendingMinMs
}
component={PendingComponent}
/>
) : null

const routeErrorComponent =
route.options.errorComponent ?? router.options.defaultErrorComponent
Expand All @@ -155,11 +182,9 @@ function MatchView({

const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only'
const ResolvedSuspenseBoundary =
// If we're on the root route, allow forcefully wrapping in suspense
(!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) &&
(route.options.wrapInSuspense ??
PendingComponent ??
((route.options.errorComponent as any)?.preload || resolvedNoSsr))
PendingComponent ??
((route.options.errorComponent as any)?.preload || resolvedNoSsr))
? React.Suspense
: SafeFragment

Expand Down Expand Up @@ -251,11 +276,13 @@ function OnRendered() {
// 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<any> | undefined
>()
const renderedRef = React.useRef<
[router: typeof router, location?: ParsedLocation<any>]
>([router])
if (renderedRef.current[0] !== router) {
renderedRef.current = [router]
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const renderedLocationKey = useStore(
router.stores.resolvedLocation,
Expand All @@ -265,22 +292,22 @@ function OnRendered() {
// eslint-disable-next-line react-hooks/rules-of-hooks
useLayoutEffect(() => {
const currentResolvedLocation = router.stores.resolvedLocation.get()
const previousResolvedLocation = prevResolvedLocationRef.current
const previousResolvedLocation = renderedRef.current[1]

if (
currentResolvedLocation &&
(!previousResolvedLocation ||
previousResolvedLocation.href !== currentResolvedLocation.href)
previousResolvedLocation.state.__TSR_key !== renderedLocationKey)
) {
router.emit({
type: 'onRendered',
...getLocationChangeInfo(
router.stores.location.get(),
currentResolvedLocation,
previousResolvedLocation ?? currentResolvedLocation,
),
})
}
prevResolvedLocationRef.current = currentResolvedLocation
renderedRef.current[1] = currentResolvedLocation
}, [renderedLocationKey, router])

return null
Expand Down Expand Up @@ -395,16 +422,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}, [key, Comp])

if (match.status === 'pending') {
const PendingComponent =
routeOptions.pendingComponent ?? router.options.defaultPendingComponent
const pendingMinMs = PendingComponent
? (routeOptions.pendingMinMs ?? router.options.defaultPendingMinMs)
: undefined
const localPromise = match._.loadPromise
if (localPromise?.status === 'pending' && pendingMinMs) {
localPromise.pendingUntil ??= Date.now() + pendingMinMs
}

throw getLoadPromise(router, match)
}

Expand Down Expand Up @@ -477,7 +494,7 @@ export const Outlet = React.memo(function OutletImpl() {
) : null

if (parentGlobalNotFound) {
return renderRouteNotFound(router, route!, undefined)
return renderRouteNotFound(router, route, undefined)
}

if (!childMatchId) {
Expand Down
17 changes: 12 additions & 5 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useRouter } from './useRouter'
import { useStructuralSharing } from './useMatch'
import { Transitioner } from './Transitioner'
import { matchContext } from './matchContext'
import { Match } from './Match'
import { Match, PendingFallback } from './Match'
import { SafeFragment } from './SafeFragment'
import type {
StructuralSharingOption,
Expand Down Expand Up @@ -51,7 +51,15 @@ export function Matches() {
const PendingComponent =
rootRoute.options.pendingComponent ?? router.options.defaultPendingComponent

const pendingElement = PendingComponent ? <PendingComponent /> : null
const pendingElement = PendingComponent ? (
<PendingFallback
matchStore={router.stores.getRouteMatchStore(rootRoute.id)}
pendingMinMs={
rootRoute.options.pendingMinMs ?? router.options.defaultPendingMinMs
}
component={PendingComponent}
/>
) : null

// Do not render a root Suspense during SSR or hydrating from SSR
const ResolvedSuspense =
Expand All @@ -76,12 +84,11 @@ export function Matches() {

function MatchesInner() {
const router = useRouter()
const _isServer = isServer ?? router.isServer
const matchId = _isServer
const matchId = (isServer ?? router.isServer)
? router.stores.firstId.get()
: // eslint-disable-next-line react-hooks/rules-of-hooks
useStore(router.stores.firstId, (id) => id)
const resetKey = _isServer
const resetKey = (isServer ?? router.isServer)
? router.stores.loadedAt.get()
: // eslint-disable-next-line react-hooks/rules-of-hooks
useStore(router.stores.loadedAt, (loadedAt) => loadedAt)
Expand Down
100 changes: 65 additions & 35 deletions packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,86 @@
'use client'

import * as React from 'react'
import { batch } from '@tanstack/react-store'
import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core'
import { useLayoutEffect } from './utils'
import { useRouter } from './useRouter'

export function Transitioner() {
const router = useRouter()
const mountLoadForRouter = React.useRef({ router, mounted: false })
const routerState = React.useRef({ router, mounted: false, generation: 0 })
const routerChanged = routerState.current.router !== router
if (routerChanged) {
routerState.current = {
router,
mounted: false,
generation: routerState.current.generation + 1,
}
}
const generation = routerState.current.generation

// `transitioning` bridges router time and React time: raised before the
// commit's startTransition, lowered by the tick effect below — which
// React runs only after committing the transition render, keeping the
// onResolved edge render-coupled. A counter (not a boolean) so nested
// transitions can never coalesce into an unobservable no-change.
// onResolved edge render-coupled.
const transitioning = React.useRef(false)
const [transitionTick, setTransitionTick] = React.useState(0)

const previous = React.useRef({
isLoading: false,
isPagePending: false,
isAnyPending: false,
})
const resolvedChangeInfo = React.useRef<
ReturnType<typeof getLocationChangeInfo> | undefined
>(undefined)

const previous = React.useRef<[isLoading: boolean, isAnyPending: boolean]>([
false,
false,
])

if (routerChanged) {
transitioning.current = false
resolvedChangeInfo.current = undefined
previous.current = [false, false]
}

// Single emitter for the load lifecycle. Level changes arrive through
// synchronous store subscriptions — which, unlike render-observed edges,
// cannot lose a flip to React batching — plus the render-coupled
// transition edge. One emitter means nothing to arbitrate.
const emitEdges = React.useCallback(() => {
const isLoading = router.stores.isLoading.get()
const isPagePending = isLoading || router.stores.hasPending.get()
const isAnyPending = isPagePending || transitioning.current
const prev = previous.current
previous.current = { isLoading, isPagePending, isAnyPending }
const isAnyPending = isLoading || transitioning.current
const [wasLoading, wasAnyPending] = previous.current
previous.current = [isLoading, isAnyPending]

const changeInfo = () =>
getLocationChangeInfo(
router.stores.location.get(),
router.stores.resolvedLocation.get(),
)

if (prev.isLoading && !isLoading) {
if (wasLoading && !isLoading) {
// The new URL has committed and the new matches are in state.matches.
router.emit({ type: 'onLoad', ...changeInfo() })
const info = changeInfo()
resolvedChangeInfo.current = info
// Expose the completed location before lifecycle subscribers run. A
// subscriber may synchronously start the next navigation; status stays
// pending until React commits the completed navigation's render.
router.stores.resolvedLocation.set(router.stores.location.get())
router.emit({ type: 'onLoad', ...info })
router.emit({ type: 'onBeforeRouteMount', ...info })
}
if (prev.isPagePending && !isPagePending) {
router.emit({ type: 'onBeforeRouteMount', ...changeInfo() })
}
if (prev.isAnyPending && !isAnyPending) {
router.emit({ type: 'onResolved', ...changeInfo() })
batch(() => {
if (wasAnyPending && !isAnyPending) {
const info = resolvedChangeInfo.current ?? changeInfo()
resolvedChangeInfo.current = undefined
router.emit({ type: 'onResolved', ...info })
if (!router.stores.isLoading.get()) {
router.stores.status.set('idle')
router.stores.resolvedLocation.set(router.stores.location.get())
})
}
}
}, [router])

router.startTransition = (fn: () => void) => {
if (routerState.current.generation !== generation) {
React.startTransition(fn)
return
}
transitioning.current = true
// Register the rising transition level so the falling edge resolves
// even when the load's own levels already settled.
Expand All @@ -68,7 +91,9 @@ export function Transitioner() {
} finally {
// A throwing commit must not strand the transition level, which
// would permanently block onResolved and the idle transition.
setTransitionTick((tick) => tick + 1)
if (routerState.current.generation === generation) {
setTransitionTick((tick) => tick + 1)
}
}
})
}
Expand All @@ -87,15 +112,12 @@ export function Transitioner() {
// produces an observable edge.
useLayoutEffect(() => {
const isLoading = router.stores.isLoading.get()
const isPagePending = isLoading || router.stores.hasPending.get()
const isAnyPending = isPagePending || transitioning.current
previous.current = { isLoading, isPagePending, isAnyPending }
const isAnyPending = isLoading || transitioning.current
previous.current = [isLoading, isAnyPending]

const isLoadingSubscription = router.stores.isLoading.subscribe(emitEdges)
const hasPendingSubscription = router.stores.hasPending.subscribe(emitEdges)
return () => {
isLoadingSubscription.unsubscribe()
hasPendingSubscription.unsubscribe()
}
}, [router, emitEdges])

Expand Down Expand Up @@ -130,15 +152,23 @@ export function Transitioner() {

// Try to load the initial location
useLayoutEffect(() => {
const historyLocation = router.history.location
const storeLocation = router.stores.location.get()
const historyChangedWhileUnmounted =
historyLocation.href !== storeLocation.publicHref ||
historyLocation.state.__TSR_key !== storeLocation.state.__TSR_key

// Hydration already loaded the first location, but persistent `router.ssr`
// must not hide a history change that happened while this provider was
// unmounted and unsubscribed.
if (
// if we are hydrating from SSR, loading is triggered in ssr-client
(typeof window !== 'undefined' && router.ssr) ||
(mountLoadForRouter.current.router === router &&
mountLoadForRouter.current.mounted)
!historyChangedWhileUnmounted &&
((typeof window !== 'undefined' && router.ssr) ||
(routerState.current.router === router && routerState.current.mounted))
) {
return
}
mountLoadForRouter.current = { router, mounted: true }
routerState.current.mounted = true

router.load().catch((err) => {
console.error(err)
Expand Down
Loading
Loading