diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 1d03204641..17eac99936 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -23,6 +23,7 @@ import type { AnyRouteMatch, ParsedLocation, RootRouteOptions, + RouterReadableStore, } from '@tanstack/router-core' type OutletMatchSelection = [ @@ -66,6 +67,24 @@ const getLoadPromise = ( return promise } +export function PendingFallback({ + matchStore, + pendingMinMs, + component: PendingComponent, +}: { + matchStore: RouterReadableStore + 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 +} + export const Match = React.memo(function MatchImpl({ matchId, }: { @@ -140,7 +159,15 @@ function MatchView({ const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent - const pendingElement = PendingComponent ? : null + const pendingElement = PendingComponent ? ( + + ) : null const routeErrorComponent = route.options.errorComponent ?? router.options.defaultErrorComponent @@ -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 @@ -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 | undefined - >() + const renderedRef = React.useRef< + [router: typeof router, location?: ParsedLocation] + >([router]) + if (renderedRef.current[0] !== router) { + renderedRef.current = [router] + } // eslint-disable-next-line react-hooks/rules-of-hooks const renderedLocationKey = useStore( router.stores.resolvedLocation, @@ -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 @@ -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) } @@ -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) { diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 62d0fc42ae..8c2d3c805c 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -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, @@ -51,7 +51,15 @@ export function Matches() { const PendingComponent = rootRoute.options.pendingComponent ?? router.options.defaultPendingComponent - const pendingElement = PendingComponent ? : null + const pendingElement = PendingComponent ? ( + + ) : null // Do not render a root Suspense during SSR or hydrating from SSR const ResolvedSuspense = @@ -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) diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index 165a486c37..2f3aee1623 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -1,28 +1,43 @@ '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 | 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, @@ -30,10 +45,9 @@ export function Transitioner() { // 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( @@ -41,23 +55,32 @@ export function Transitioner() { 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. @@ -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) + } } }) } @@ -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]) @@ -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) diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index 8ab0c21f07..502c8c5b3b 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -570,6 +570,75 @@ test('Match view re-renders when same-id ssr field changes', () => { rendered.unmount() }) +test('Outlet follows a legacy notFoundRoute when its same-id match moves to a shallower index', async () => { + const root = createRootRoute({ component: Outlet }) + const parent = createRoute({ + getParentRoute: () => root, + path: '/parent', + component: () => ( +
+ Parent layout + +
+ ), + }) + const known = createRoute({ + getParentRoute: () => parent, + path: '/known', + }) + const home = createRoute({ + getParentRoute: () => root, + path: '/home', + component: () =>
Home
, + }) + const legacyNotFound = createRoute({ + getParentRoute: () => root, + path: '/404', + loader: () => 'not found', + component: () =>
Legacy not found
, + staleTime: Infinity, + gcTime: Infinity, + }) + const router = createRouter({ + routeTree: root.addChildren([parent.addChildren([known]), home]), + history: createMemoryHistory({ initialEntries: ['/parent/missing'] }), + notFoundRoute: legacyNotFound, + }) + + await router.load() + const rootMatchId = router.stores.matches.get()[0]!.id + const rendered = render( + + + , + ) + + expect(rendered.container).toHaveTextContent('Parent layout') + expect(rendered.container).toHaveTextContent('Legacy not found') + + await act(async () => { + await router.navigate({ to: '/home' }) + }) + expect( + router.stores.cachedMatches + .get() + .find((match) => match.routeId === legacyNotFound.id)?.index, + ).toBe(2) + + await act(async () => { + await router.navigate({ to: '/missing' } as any) + }) + + expect(rendered.container).not.toHaveTextContent('Parent layout') + expect(rendered.container).toHaveTextContent('Legacy not found') + expect( + router.stores.matches + .get() + .find((match) => match.routeId === legacyNotFound.id)?.index, + ).toBe(1) + rendered.unmount() +}) + test('fast load before pendingMs does not arm pendingUntil', async () => { vi.useFakeTimers() diff --git a/packages/react-router/tests/component-preload-retry-pending-min.test.tsx b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx new file mode 100644 index 0000000000..25dee12a4e --- /dev/null +++ b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createControlledPromise } from '@tanstack/router-core' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + cleanup() +}) + +/** + * A component-only route can fail while preloading its code, then retry from + * its error UI through invalidate(). The retry is a fresh pending generation: + * it needs a match-local load promise so the rendered pending component can + * attach pendingMinMs to the work that actually owns readiness. + */ +test('component preload retry owns readiness and honors pendingMinMs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const retryChunk = createControlledPromise() + let preloadAttempt = 0 + let retryPromise: Promise | undefined + let retrySettled = false + + const Page = Object.assign( + () =>
Page content
, + { + preload: vi.fn(() => { + preloadAttempt++ + return preloadAttempt === 1 + ? Promise.reject(new Error('initial chunk request failed')) + : retryChunk + }), + }, + ) + + function RetryError({ reset }: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute({}) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RetryError, + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => ( +
Loading page...
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + expect( + await screen.findByRole('button', { name: 'Retry chunk' }), + ).toBeInTheDocument() + expect(Page.preload).toHaveBeenCalledTimes(1) + + vi.useFakeTimers() + fireEvent.click(screen.getByRole('button', { name: 'Retry chunk' })) + + // Let pendingMs: 0 publish the retry lane. + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('page-pending')).toBeInTheDocument() + + await act(async () => { + retryChunk.resolve() + await Promise.resolve() + }) + + expect.soft(retrySettled).toBe(false) + expect.soft(screen.queryByTestId('page-pending')).toBeInTheDocument() + expect.soft(screen.queryByTestId('page-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('page-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await retryPromise + }) + + expect(Page.preload).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('page-content')).toBeInTheDocument() + expect(screen.queryByTestId('page-pending')).not.toBeInTheDocument() +}) diff --git a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx new file mode 100644 index 0000000000..b00d4a4009 --- /dev/null +++ b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,195 @@ +import * as React from 'react' +import { act } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { createControlledPromise } from '@tanstack/router-core' +import { hydrate } from '../src/ssr/client' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + notFound, +} from '../src' +import type { TsrSsrGlobal } from '../src/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.restoreAllMocks() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +describe('hydrating a server-capped boundary lane', () => { + test.each([ + ['error', 'parent'], + ['notFound', 'parent'], + ['error', 'root'], + ['notFound', 'root'], + ] as const)( + 'keeps the server-rendered %s %s boundary visible while the client replays it', + async (outcome, boundary) => { + const routeFailure = + outcome === 'notFound' ? notFound() : new Error('server route failure') + const childLoader = vi.fn(() => 'child data') + + const makeRouteTree = ( + head?: () => Record | Promise>, + ) => { + const boundaryOptions = { + beforeLoad: () => { + throw routeFailure + }, + head, + pendingComponent: () => ( +
Boundary pending
+ ), + errorComponent: () => ( +
Boundary error
+ ), + notFoundComponent: () => ( +
Boundary not found
+ ), + } + const rootRoute = createRootRoute({ + component: Outlet, + ...(boundary === 'root' ? boundaryOptions : {}), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + ...(boundary === 'parent' ? boundaryOptions : {}), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: () =>
Child
, + }) + return { + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + } + } + + const serverRouter = createRouter({ + ...makeRouteTree(), + ...(boundary === 'root' ? { isShell: true } : {}), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + serverRouter.isServer = true + await serverRouter.load() + + const serverMatches = serverRouter.stores.matches.get() + expect(serverMatches).toHaveLength(boundary === 'root' ? 1 : 2) + const serverBoundary = serverMatches.at(-1)! + if (outcome === 'notFound' && boundary === 'root') { + expect(serverBoundary).toMatchObject({ + status: 'success', + globalNotFound: true, + }) + } else { + expect(serverBoundary.status).toBe(outcome) + } + const serverHtml = renderToString( + , + ) + expect(serverHtml).toContain( + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error', + ) + + // Hydration reconstructs the boundary head once. Keep its second call, + // from the follow-up boundary replay, pending so the first client frame + // is observable instead of racing the final commit. + const replayAssets = createControlledPromise>() + const clientHead = vi + .fn<() => Record | Promise>>() + .mockReturnValueOnce({}) + .mockReturnValueOnce(replayAssets) + const clientRouter = createRouter({ + ...makeRouteTree(clientHead), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: serverMatches.at(-1)!.id, + matches: serverMatches.map((match) => ({ + i: match.id, + u: match.updatedAt, + s: match.status, + l: match.loaderData, + e: match.error, + ssr: match.ssr, + ...(match.globalNotFound ? { g: true } : {}), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(clientRouter) + await vi.waitFor(() => { + expect(clientHead).toHaveBeenCalledTimes(2) + expect(clientRouter.stores.isLoading.get()).toBe(true) + }) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const root = hydrateRoot( + container, + , + ) + testCleanups.push(async () => { + replayAssets.resolve({}) + await clientRouter.latestLoadPromise + await act(() => root.unmount()) + }) + + await act(async () => { + await Promise.resolve() + }) + + // A shorter dehydrated lane means SPA mode only for an actual shell. + // Here it is shorter because the server already rendered a terminal + // boundary, so hydration must not replace that boundary with pending UI. + expect(container).toHaveTextContent( + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error', + ) + expect(container).not.toHaveTextContent('Boundary pending') + expect(consoleError.mock.calls.flat().join(' ')).not.toMatch( + /hydration|did not match/i, + ) + expect(childLoader).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/react-router/tests/on-rendered-same-href-state.test.tsx b/packages/react-router/tests/on-rendered-same-href-state.test.tsx new file mode 100644 index 0000000000..88e097a904 --- /dev/null +++ b/packages/react-router/tests/on-rendered-same-href-state.test.tsx @@ -0,0 +1,50 @@ +import { act } from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('onRendered fires for a same-href navigation with a new history key', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + await act(() => + router.navigate({ + to: '/', + state: { sameHrefState: true } as any, + }), + ) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect(event.fromLocation?.state.sameHrefState).toBeUndefined() + expect(event.toLocation.state.sameHrefState).toBe(true) + expect(event.fromLocation?.href).toBe('/') + expect(event.toLocation.href).toBe('/') + expect(event.hrefChanged).toBe(false) + + unsubscribe() +}) diff --git a/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..0ad93e0605 --- /dev/null +++ b/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,112 @@ +import * as React from 'react' +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { createControlledPromise } from '@tanstack/router-core' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + vi.useRealTimers() + cleanup() +}) + +test('a mounted pending fallback follows a replacement load promise for the same match', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + component: () => , + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Loading page...
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => ({ generation })) : { generation } + }, + component: () => ( +
+ Generation {pageRoute.useLoaderData().generation} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + + let firstInvalidation!: Promise + await act(async () => { + firstInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(2) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + }) + + let secondInvalidation!: Promise + await act(async () => { + secondInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(3) + + await act(async () => { + firstReload.resolve() + await Promise.resolve() + }) + + // Completing the superseded generation cannot release the currently mounted + // fallback or restore its stale loader data. + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + let secondSettled = false + void secondInvalidation.then(() => { + secondSettled = true + }) + await act(async () => { + secondReload.resolve() + await Promise.resolve() + }) + + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await Promise.all([firstInvalidation, secondInvalidation]) + }) + + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() +}) diff --git a/packages/react-router/tests/root-pending-min.test.tsx b/packages/react-router/tests/root-pending-min.test.tsx new file mode 100644 index 0000000000..8b5952f16c --- /dev/null +++ b/packages/react-router/tests/root-pending-min.test.tsx @@ -0,0 +1,380 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, expect, test, vi } from 'vitest' +import { hydrate } from '../src/ssr/client' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() + vi.useRealTimers() + delete window.$_TSR +}) + +test('root pending fallback remains visible through pendingMinMs', async () => { + vi.useFakeTimers() + + const loaderGate = createControlledPromise() + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => loaderGate, + component: () =>
Loaded
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + const loadPromise = router.latestLoadPromise! + loaderGate.resolve('loaded') + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await loadPromise + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toBeInTheDocument() +}) + +test('hydrated routers show the root pending fallback through pendingMinMs on a later reload', async () => { + const reloadGate = createControlledPromise() + const rootLoader = vi.fn(() => reloadGate.then(() => ({ generation: 2 }))) + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: rootLoader, + component: () => ( +
+ Generation {rootRoute.useLoaderData().generation} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const rootMatch = router.matchRoutes(router.stores.location.get())[0]! + + // This is the same public bootstrap shape produced by the server. Calling + // hydrate() ensures router.ssr and the active match are established through + // the real client hydration path rather than by mutating router stores. + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: rootMatch.id, + matches: [ + { + i: rootMatch.id, + s: 'success', + ssr: true, + l: { generation: 1 }, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + expect(router.ssr).toBeDefined() + + render() + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 1') + expect(rootLoader).not.toHaveBeenCalled() + + vi.useFakeTimers() + + let invalidation!: Promise + await act(async () => { + invalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.getByTestId('root-content')).not.toBeVisible() + + await act(async () => { + reloadGate.resolve() + await Promise.resolve() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.getByTestId('root-content')).not.toBeVisible() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await invalidation + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 2') +}) + +test('root pending fallback follows an overlapping forcePending generation', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => ({ generation })) : { generation } + }, + component: () => ( +
+ Generation {rootRoute.useLoaderData().generation} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + + let firstInvalidation!: Promise + await act(async () => { + firstInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(2) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + }) + + let secondInvalidation!: Promise + await act(async () => { + secondInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(3) + + await act(async () => { + firstReload.resolve() + await Promise.resolve() + }) + + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + let secondSettled = false + void secondInvalidation.then(() => { + secondSettled = true + }) + await act(async () => { + secondReload.resolve() + await Promise.resolve() + }) + + expect(secondSettled).toBe(false) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await Promise.all([firstInvalidation, secondInvalidation]) + }) + + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 3') + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() +}) + +test('the root suspense boundary stays stable across hydration', async () => { + const mounts = vi.fn() + const unmounts = vi.fn() + const initializers = vi.fn(() => 'preserved') + + const rootRoute = createRootRoute({ + pendingComponent: () =>
Root pending
, + component: function RootComponent() { + const [value] = React.useState(initializers) + React.useEffect(() => { + mounts() + return unmounts + }, []) + return
{value}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + + // Model the server render and the client router produced by hydrate(). The + // outer root boundary is intentionally absent from both trees, while the + // route's own pending boundary is present in both. + router.ssr = { manifest: { routes: {} } } + router.isServer = true + const html = renderToString() + router.isServer = false + expect(html).toContain('') + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = hydrateRoot(container, ) + testCleanups.push(async () => { + await act(() => root.unmount()) + consoleError.mockRestore() + container.remove() + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(container).toHaveTextContent('preserved') + // One initializer belongs to the server render and one to client + // hydration. The stable boundary must preserve that hydrated client + // instance instead of creating a third one. + expect(initializers).toHaveBeenCalledTimes(2) + expect(mounts).toHaveBeenCalledTimes(1) + expect(unmounts).not.toHaveBeenCalled() + expect(consoleError).not.toHaveBeenCalled() +}) + +test('the root pending boundary contains a route component that suspends during server rendering', async () => { + const gate = createControlledPromise() + const rootRoute = createRootRoute({ + pendingComponent: () =>
Server root pending
, + component: () => { + throw gate + }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + router.isServer = true + await router.load() + + const html = renderToString() + + // renderToString cannot wait for Suspense, but the root route's stable + // boundary contains the suspension and emits its fallback. Streaming SSR + // can wait for the same boundary instead. + expect(html).toContain('Server root pending') + expect(html).toContain('') +}) + +test('remounting a hydrated router loads a history change made while unmounted', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next route
, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history, + }) + const matches = router.matchRoutes(router.stores.location.get()) + const now = Date.now() + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: matches[matches.length - 1]!.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: now, + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + const firstRender = render() + expect(screen.getByText('Index route')).toBeInTheDocument() + + firstRender.unmount() + history.push('/next') + + const secondRender = render() + expect(await screen.findByText('Next route')).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/next') + + secondRender.unmount() + history.push('/next', { marker: 'new history entry' }) + + render() + await vi.waitFor(() => { + expect((router.state.location.state as any).marker).toBe( + 'new history entry', + ) + }) +}) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index ebd445e89c..cf02a1189e 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -8,7 +8,11 @@ import { waitFor, } from '@testing-library/react' import { z } from 'zod' -import { composeRewrites, notFound } from '@tanstack/router-core' +import { + composeRewrites, + createControlledPromise, + notFound, +} from '@tanstack/router-core' import { Link, Outlet, @@ -857,6 +861,150 @@ describe('encoding/decoding: URL path segment', () => { }) describe('router emits events during rendering', () => { + it.each(['onLoad', 'onBeforeRouteMount'] as const)( + 'keeps completed-navigation lifecycle state stable when %s starts a new navigation', + async (reentrantEvent) => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + await waitFor(() => { + expect(screen.getByText('Index')).toBeInTheDocument() + expect(router.stores.status.get()).toBe('idle') + }) + + const loadPaths: Array = [] + const beforeMountPaths: Array = [] + const resolvedPaths: Array = [] + let nextNavigation: Promise | undefined + const startNextNavigation = (pathname: string) => { + if (pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + } + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + loadPaths.push(event.toLocation.pathname) + if (reentrantEvent === 'onLoad') { + startNextNavigation(event.toLocation.pathname) + } + }), + router.subscribe('onBeforeRouteMount', (event) => { + beforeMountPaths.push(event.toLocation.pathname) + if (reentrantEvent === 'onBeforeRouteMount') { + startNextNavigation(event.toLocation.pathname) + } + }), + router.subscribe('onResolved', (event) => { + resolvedPaths.push(event.toLocation.pathname) + }), + ] + + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + expect(router.state.location.pathname).toBe('/next') + expect(router.stores.isLoading.get()).toBe(true) + expect(router.stores.status.get()).toBe('pending') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/first') + expect(loadPaths).toEqual(['/first']) + expect(beforeMountPaths).toEqual(['/first']) + expect(resolvedPaths).toEqual([]) + }) + + nextLoader.resolve() + await act(async () => { + await Promise.all([firstNavigation, nextNavigation!]) + }) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + expect(router.stores.isLoading.get()).toBe(false) + expect(router.stores.status.get()).toBe('idle') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/next') + expect(loadPaths).toEqual(['/first', '/next']) + expect(beforeMountPaths).toEqual(['/first', '/next']) + expect(resolvedPaths).toEqual(['/next']) + }) + + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + }, + ) + + it('does not let an onResolved navigation get marked idle by the previous edge', async () => { + const nextLoader = createControlledPromise() + const nextLoaderFn = vi.fn(() => nextLoader) + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: nextLoaderFn, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + render() + await waitFor(() => expect(router.state.status).toBe('idle')) + + let nextNavigation: Promise | undefined + const unsubscribe = router.subscribe('onResolved', (event) => { + if (event.toLocation.pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + }) + + const firstNavigation = router.navigate({ to: '/first' }) + await waitFor(() => expect(router.state.location.pathname).toBe('/next')) + await waitFor(() => expect(nextLoaderFn).toHaveBeenCalledTimes(1)) + + expect(router.state.isLoading).toBe(true) + expect(router.state.status).toBe('pending') + expect(router.state.resolvedLocation?.pathname).toBe('/first') + + nextLoader.resolve() + await act(async () => { + await Promise.all([firstNavigation, nextNavigation]) + }) + await waitFor(() => expect(router.state.status).toBe('idle')) + expect(router.state.resolvedLocation?.pathname).toBe('/next') + + unsubscribe() + }) + it('during initial load, should emit the "onResolved" event', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), diff --git a/packages/react-router/tests/transitioner-listener-errors.test.tsx b/packages/react-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..88c16b77c7 --- /dev/null +++ b/packages/react-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + const unhandledRejection = vi.fn() + const laterOnLoad = vi.fn() + process.on('unhandledRejection', unhandledRejection) + testCleanups.push(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index route')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const unsubscribe = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + const unsubscribeLater = router.subscribe('onLoad', laterOnLoad) + testCleanups.push(unsubscribeLater) + + await act(() => router.navigate({ to: '/first' })) + + expect(screen.getByText('First route')).toBeInTheDocument() + + unsubscribe() + await act(() => router.navigate({ to: '/second' })) + + expect(screen.getByText('Second route')).toBeInTheDocument() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + expect(secondOnEnter).toHaveBeenCalledTimes(1) + expect(laterOnLoad).toHaveBeenCalledTimes(2) + await waitFor(() => expect(router.state.status).toBe('idle')) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unhandledRejection).toHaveBeenCalledWith( + listenerError, + expect.anything(), + ) +}) diff --git a/packages/react-router/tests/transitioner-router-swap.test.tsx b/packages/react-router/tests/transitioner-router-swap.test.tsx new file mode 100644 index 0000000000..505483de87 --- /dev/null +++ b/packages/react-router/tests/transitioner-router-swap.test.tsx @@ -0,0 +1,100 @@ +import { act } from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +function deferred() { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +test('a load finishing on an old router does not resolve the replacement router', async () => { + const slowLoader = deferred() + const rootA = createRootRoute({ component: () => }) + const indexA = createRoute({ + getParentRoute: () => rootA, + path: '/', + component: () =>
Router A
, + }) + const slowA = createRoute({ + getParentRoute: () => rootA, + path: '/slow', + loader: () => slowLoader.promise, + component: () =>
Slow A
, + }) + const routerA = createRouter({ + routeTree: rootA.addChildren([indexA, slowA]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const rootB = createRootRoute({ component: () => }) + const indexB = createRoute({ + getParentRoute: () => rootB, + path: '/b', + component: () =>
Router B
, + }) + const routerB = createRouter({ + routeTree: rootB.addChildren([indexB]), + history: createMemoryHistory({ initialEntries: ['/b'] }), + }) + + const view = render() + expect(await screen.findByText('Router A')).toBeInTheDocument() + + let oldNavigation!: Promise + act(() => { + oldNavigation = routerA.navigate({ to: '/slow' }) + }) + await waitFor(() => expect(routerA.state.isLoading).toBe(true)) + + const onRendered = vi.fn() + const unsubscribeRendered = routerB.subscribe('onRendered', onRendered) + view.rerender() + expect(await screen.findByText('Router B')).toBeInTheDocument() + await waitFor(() => expect(routerB.state.status).toBe('idle')) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + expect(onRendered.mock.calls[0]![0].fromLocation?.pathname).toBe('/b') + expect(onRendered.mock.calls[0]![0].toLocation.pathname).toBe('/b') + + const onLoad = vi.fn() + const onBeforeRouteMount = vi.fn() + const onResolved = vi.fn() + const unsubscribers = [ + routerB.subscribe('onLoad', onLoad), + routerB.subscribe('onBeforeRouteMount', onBeforeRouteMount), + routerB.subscribe('onResolved', onResolved), + ] + + slowLoader.resolve() + await act(() => oldNavigation) + + // Flush the old transition's state update and the replacement provider's + // layout effects. Router B did no work during this interval. + await act(async () => {}) + + expect(onLoad).not.toHaveBeenCalled() + expect(onBeforeRouteMount).not.toHaveBeenCalled() + expect(onResolved).not.toHaveBeenCalled() + expect(routerA.state.isLoading).toBe(false) + expect(routerA.state.matches.at(-1)?.pathname).toBe('/slow') + expect(screen.getByText('Router B')).toBeInTheDocument() + expect(routerB.state.status).toBe('idle') + expect(routerB.state.resolvedLocation?.pathname).toBe('/b') + + unsubscribeRendered() + unsubscribers.forEach((unsubscribe) => unsubscribe()) +}) diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md index 4aa0a86b61..719afc3165 100644 --- a/packages/router-core/INTERNALS.md +++ b/packages/router-core/INTERNALS.md @@ -74,6 +74,14 @@ runtime fields: - `_.dehydrated`: client hydration marker. - `_.error`: private redirect marker used by preload borrowing. notFound and regular error outcomes live on public match state. +- `_.loaderAbortController`: controller whose signal belongs to the loader data + currently carried by the match. It can differ from `abortController` when a + match reuses or adopts another lane's loader generation. +- `_.loaderGeneration`: monotonic client loader invocation order. Concurrent + preload asset projection may finish out of order; cache publication rejects + an older generation after a newer one already owns the same match id. +- `_.preloadLoaderSuccess`: private provenance marker proving that a preload + lane produced or adopted a successful loader generation that it may cache. - `_displayPending`: hydration-only flag that asks frameworks to render pending UI for a hydration display match. The display match can be the SPA fallback or the first `ssr: false`/`data-only` match, so it is not always dehydrated. @@ -189,6 +197,11 @@ preloads return without projecting assets or caching speculative matches. Top-level client navigation is current when `router.latestLoadPromise === loadPromise`. +Preload location building and synchronous matching capture the current +`latestLoadPromise` before either phase starts. If user search/params/context +code starts a navigation, the speculative pass stops later matching callbacks +and discards its partial lane before starting route work. + Background work is current when: - `router._backgroundLoad === token`. @@ -258,7 +271,7 @@ creates the initial lane. 3. Validates search params; for new matches, extracts and validates strict path params. 4. Computes loader deps and the match id. -5. Reuses an existing match by id when possible. +5. Reuses an existing match by id and lane index when possible. 6. Creates a fresh `AbortController`. 7. Sets `cause` to `preload`, `stay`, or `enter`. 8. Runs route `context()` for new matches. @@ -282,6 +295,28 @@ pending owners keep readiness ownership across rematching, a dehydrated match can legitimately hold a pending load promise, and dropping the marker would make the follow-up load re-run its server work. That copy is a new generation. The old match may still exist in an active, pending, or cached pool. +While that marker is present, rematching also preserves the dehydrated +`globalNotFound` outcome so a root boundary can be replayed before ordinary +client matching is allowed to recompute the flag. + +The stored lane index is part of reuse eligibility even though it is not part +of the public match id. The deprecated `notFoundRoute` can append the same +route id below partial branches of different depths. Reusing that snapshot at +a new index would also reuse route context derived from its old effective +parent, so matching creates a fresh generation and reruns context, beforeLoad, +and loader work instead. + +Development route HMR adds another reuse guard. Matches carry the live route's +hot generation, and a generation mismatch forces a fresh match for that route +and the remaining descendant suffix. Parsed params and route context are +generation-derived; rebuilding only the edited parent would still leave child +route context derived from its previous parent. This hot generation is separate +from lazy-chunk invocation generations, which also advance during ordinary +development loading and therefore cannot identify HMR by themselves. +When HMR removes `beforeLoad`, the immediate compatibility clear rebuilds active +and pending context against each match's own lane. Same-id generations can +coexist in both pools, so looking up a parent with pending-first global priority +would leak speculative parent context into the rendered active match. ## Client Top-Level Navigation Load @@ -299,11 +334,12 @@ sequenceDiagram App->>Router: router.load(opts) Router->>Router: create loadPromise + Router->>Router: updateLatestLocation() + Router->>Router: matchRoutes(latestLocation) + Router->>Router: verify no reentrant load took ownership Router->>Router: latestLoadPromise = loadPromise Router->>Router: abort active background load if any Router->>Router: cancelMatches() - Router->>Router: updateLatestLocation() - Router->>Router: matchRoutes(latestLocation) Router->>Stores: status=pending, isLoading=true, location=next Router->>Stores: setPending(pendingMatches), evict overlapping cache Router->>Loader: loadClientMatches(loadContext) @@ -328,11 +364,78 @@ sequenceDiagram end ``` +The final client commit is scheduled from inside the view-transition update +callback. A custom/native wrapper can reject before invoking that callback; in +that case the still-current navigation schedules the same final commit outside +the failed wrapper and keeps the rejection globally observable. A callback +that was already scheduled is never scheduled twice, including when the +wrapper rejects after it ran or invokes it late. + Core sets `status` to `pending` at the start. Normal final commits and background-only exits clear `isLoading`/pending; foreground redirects clear pending and start a replacement navigation, whose load later clears -`isLoading`. Framework transitioners are responsible for later marking the -router status idle and updating `resolvedLocation`. +`isLoading`. When `isLoading` falls, framework transitioners publish the +completed location through `resolvedLocation` before emitting `onLoad` and +`onBeforeRouteMount`. This lets a lifecycle subscriber that synchronously +starts another navigation observe the completed location as the new +navigation's origin. The router remains `pending` until the framework has +committed that completed navigation's render; only then does the transitioner +emit `onResolved` and mark the router `idle`. + +Synchronous rematching happens before the router aborts the generations that +currently own active, pending, or background work. `loaderDeps`, route context, +and location parsing can throw while constructing a lane; until that succeeds, +there is no replacement owner and the existing generation's abort signal must +remain live. The candidate load also does not replace `latestLoadPromise` until +matching finishes. If route context navigates synchronously, that nested load +becomes current and the candidate stops matching before invoking later +`validateSearch`, `loaderDeps`, loader-dependency serialization, param parsing, +or route-context callbacks, then returns without publishing. After a successful +preflight cancels the previous generation, any readiness promise copied from +that generation is discarded if cancellation settled it; the new generation +must create its own pending promise. When the same pending match keeps +presenting one continuous fallback, the fresh promise inherits the old +promise's `pendingUntil`: loader ownership changed, but the user-visible +minimum-display session did not restart. + +Every owned active, pending, background, and final lane keeps +`match.index === lane array position`. New matches receive the loop index, and +reconstructed matches overwrite any copied index defensively. The deprecated +`notFoundRoute` requires a fresh generation when the same id moves between +depths, because its effective parent context changed. Failure/not-found +reduction only truncates lane suffixes and therefore preserves the invariant. +Hydration first rematches the full client URL and overlays dehydrated data by +id, so a capped server lane also does not renumber the client lane. + +The cache is a flat pool, not a lane. A cached snapshot keeps the index of the +lane that produced it; its offset inside `cachedMatches` is unrelated, and its +stored index may differ from a later lane that reuses the same id. Cache +reconciliation must therefore remove reused entries by match id (or pool +membership), never by indexing a candidate lane with the cached snapshot's +old index. Borrowed preload matches are another private, read-only exception: +they retain the owner lane's index and are never published or cached as owned +preload work. + +For framework lifecycle events, `isLoading` is the single page-pending level. +Publishing pending UI during a foreground pass forces a final commit, so that +pass cannot leave an active `status: 'pending'` match behind after +`isLoading` falls. `invalidate()` can synchronously reset selected error, +not-found, and `forcePending` matches before calling `load()`. The client load +then either raises `isLoading` during the same call, or, if synchronous +rematching throws first, `invalidate()` restores the previous terminal active +lane before it returns. Transitioners emit both `onLoad` and +`onBeforeRouteMount` when `isLoading` falls and do not need a second derived +scan of active match statuses. + +When `forcePending` already made an active same-href, same-id match render +pending UI, a successfully preflighted replacement connects its fresh +match-local readiness promise to that active match immediately after canceling +the old generation. Only readiness is shared; replacement context and data +remain private. This lets the already-visible fallback arm `pendingMinMs` even +when the reload finishes before its normal `pendingMs` publication timer. +Cross-location navigations (including redirects through a shared root id) +receive replacement readiness through normal pending publication rather than +this early active-match bridge. A superseded or redirected load does not settle its public `router.load()` promise as soon as its own pass ends. After resolving its private @@ -341,6 +444,11 @@ navigation chain drains. Callers of `router.load()`/`router.invalidate()` rely on observing post-settlement state, and the preload borrow protocol uses the foreground load promise as its "committed or gone" signal. +Router event subscribers are observers, not part of the commit transaction. +`router.emit()` isolates each listener: a thrown listener is rethrown through a +rejected promise for global observability, while later listeners and core or +framework finalization continue. + ### Background-only foreground pass When a same-href load finds only non-blocking stale reloads, the foreground pass @@ -467,6 +575,13 @@ Core owns `pendingMs`. Framework match components own `pendingMinMs` by writing still-pending local load promise. Core later observes `pendingUntil` before committing success/error/notFound/redirect. +`pendingMinMs` belongs to a continuous fallback visibility session, not to an +internal loader generation. If an overlapping load replaces the readiness +promise for the same still-pending match, the new promise inherits the existing +absolute `pendingUntil` deadline. Restarting `now + pendingMinMs` for every +generation would stack artificial delays across invalidations or redirect +chains even though the fallback never left the screen. + `pendingMs` publication normally goes through a timer on the load promise. One exception: when the effective `pendingMs` is `<= 0` and nothing is rendered yet (the active match store is empty, as on a bare initial load), @@ -502,6 +617,8 @@ Important invariants: - Pending publication does not run lifecycle hooks. - Pending publication does not reconcile cache. - Pending publication does not consume the final view transition. +- Lifecycle diffs remain anchored to the last final/background commit, not the + render-ready pending lane stored temporarily in active matches. - After pending UI, a renderable same-href outcome still needs a final commit. Redirect control flow waits any pending minimum and then navigates instead. @@ -523,6 +640,10 @@ Descendants after that match are removed after the promise reduction. Core does not select an ancestor error boundary for route loading errors; framework error boundaries handle render-time propagation later. +When a deeper concurrent loader returns notFound, the already-committed +shallower regular error remains the render boundary. The notFound must not +overwrite that error or extend the lane below `inner.badIndex`. + ### notFound A notFound chooses a notFound boundary with `getNotFoundBoundaryIndex()`. That @@ -558,6 +679,12 @@ rejected `lazyFn` chunk is evicted from `route._lazyPromise` the same way, so the next load generation retries the import; awaiters of the evicted promise still own the rejection. +Route chunk bookkeeping is generation-owned across HMR. A hot route update +retires its route-level lazy/component promises and clears their loaded flags. +Late completion from the retired generation must not overwrite current lazy +options, mark current components loaded, clear a replacement promise, or start +component retries on behalf of the stale caller. + ```mermaid flowchart TD F["loader/beforeLoad/chunk throws"] --> N["normalizeRouteFailure"] @@ -592,11 +719,11 @@ boundaries. | notFound | Descendants after the selected notFound boundary are popped. | The chosen boundary is the renderable leaf for this outcome. | | Background error/notFound | The copied background lane is trimmed before atomic publication. | Active state must not see partial stale data or stale head output. | | Final commit | Replaced active matches are moved into cache if eligible. | The new active lane owns rendering; old successful loader matches may be reused later. | -| Preload finish | Borrowed matches are not cached; owned successful preload matches may be cached. | A preload must not take ownership of active or pending foreground matches. | +| Preload finish | Borrowed matches are not cached; owned successful loader generations may be cached. Loaderless matches remain transient. | A preload must not take ownership of active or pending foreground matches. | -Lane shortening must settle load promises for removed matches. Otherwise -Suspense/preload waiters can remain pending after the match is no longer -reachable. +Lane shortening must settle load promises and abort unretained controllers for +removed matches. Otherwise Suspense/preload waiters can remain pending and +deferred requests can remain live after the match is no longer reachable. ## Preloads and Match Borrowing @@ -609,8 +736,9 @@ Preloading has two kinds of matches: - **Borrowed matches**: ids already present in the active or pending lanes. The preload observes these read-only and does not cache them as its own work. -- **Owned preload matches**: `match.preload` entries that are not active or - pending when the successful preload is cached. +- **Owned preload matches**: `match.preload` entries whose loader generation + completed successfully in this pass (or was adopted from another successful + preload) and that are not active or pending when the result is cached. ```mermaid sequenceDiagram @@ -698,8 +826,9 @@ Adoption is deliberately narrow: itself be waiting on this navigation through the borrow protocol, and joining would deadlock the pair. Once the donor's loader started, its serial phase is over by construction and the preload settles - independently — the preload's finalizer settles every owned load promise, - so the adoption wait cannot hang. + independently. The adopter races donor readiness against its own readiness: + supersession settles the latter, then currentness fails through the normal + cancellation sentinel without aborting the independent donor preload. - Private lanes stay private: nothing enters `cachedMatches` before the preload finishes, and the preload's own cache write already skips ids that became active in the meantime. @@ -746,6 +875,10 @@ Background work is token-owned: - Starting a new background load aborts the old token. - Starting a foreground load aborts any active background token and clears fetching markers. +- The token owns batch currentness only. Every selected match gets its own + loader `AbortController`; aborting the batch token cascades to all of those + controllers. This lets failure/notFound trimming abort a discarded loader + generation without aborting successful data retained elsewhere in the batch. - A background token is current only while the active location href still matches and no foreground pending location exists. - Once a worker observes that the token is stale, cancellation is sticky: the @@ -839,8 +972,11 @@ Client projection: `setMatches()`. - Foreground and background projection pass `isCurrent()` checks so stale lanes do not mutate or publish assets. -- Preload projection filters to `match.preload` entries and relies on - owned-match cache checks instead of an `isCurrent()` callback. +- Preload projection filters to `match.preload` entries and receives a + currentness callback for every borrowed active/pending match. Equivalent + replacements of passive routes (no loader/beforeLoad/context and unchanged + params/search/context) remain safe to borrow; other generation changes + abandon the speculative projection and cache write. - Handles `head()` and `scripts()`, not headers. - If ownership is lost after an async `head()` or `scripts()` was started, the projector observes the abandoned promise with `Promise.allSettled()` before @@ -862,11 +998,11 @@ Server projection: behavior and are never dropped because a decorative `head()`/`scripts()` failed, and vice versa. - If any asset hook throws synchronously, projection commits the - sync-available values for the other kinds and abandons still-pending - decorative promises, observing them with `Promise.allSettled()` so they - cannot become unhandled — except a pending `headers()` promise, which is - always awaited (and committed on success) even after a sync decorative - throw, because headers are response behavior. + sync-available values for the other kinds and does not wait for pending + decorative promises. It observes their settlement so they cannot become + unhandled. A pending `headers()` promise is still awaited because headers are + response behavior; decorative work that happens to settle before those + headers may be committed opportunistically, but it never delays the response. - Async server projection uses `Promise.allSettled()` over `head()`, `scripts()`, and `headers()`; a rejected hook logs and commits `undefined` for that kind only, so one rejected asset hook does not leave another hook @@ -877,13 +1013,18 @@ Hydration projection: - Is not the same function as normal client projection. - `ssr-client.ts` waits for route chunks first because lazy chunks can install route context, head, and scripts. -- It reconstructs route context from dehydrated data where available, then - executes head and scripts for each match in the initial hydration lane — - except `ssr: false` matches. The server rendered no assets for those +- It reconstructs route context for the entire hydration lane from dehydrated + data before executing any head or scripts hook, so an ancestor asset hook can + safely inspect descendant contexts through `matches`. Asset hooks still skip + `ssr: false` matches. The server rendered no assets for those (including matches it omitted below a server error/notFound boundary, which the dehydrated payload also marks `ssr: false`), and their hooks could only run with missing or stale loader data; the follow-up client load projects assets for the lane it actually commits. +- It verifies the exact hydration lane generation before and after every + asynchronous boundary and after synchronous route context reconstruction. + A context hook can navigate immediately, so stale head/scripts must not run + even when supersession happens without an `await` inside the hook. - A `notFound()` thrown by hydration head/scripts records a notFound-shaped match error and continues. Other hydration asset/context errors reject, settle matches, and clear display pending. @@ -918,6 +1059,23 @@ the pending pool so the same match is not canceled twice. Settling clears `loadPromise`, clears pending timeout, and resolves the promise if it is still pending. +Published lane replacement reconciles both per-pass `abortController` values +and `_.loaderAbortController` values. A controller is aborted only after no +next active match retains it. Cache eviction and abandoned private preload work +perform the broader equivalent check across active, pending, cached, and other +registered preload lanes. Background batches use separate per-match loader +controllers beneath their batch token, so a trimmed descendant is abortable +even when a retained ancestor from the same batch stays live. Thus adopted data +keeps its original loader signal alive, while clearCache/GC/discarded work +aborts a loader generation once its last owner disappears. A soft background +`AbortError` retains old loader data and therefore also retains that old data's +controller. + +Foreground redirects and error/notFound/fatal lane trimming are discarded +private work too. Redirect cleanup runs after removing the pending store; +trimmed matches are retained on the load context until final publication (or +preload cleanup), then passed through the same retained-controller scan. + After foreground/background work detects ownership loss, it must not: - run route error handling, @@ -1027,7 +1185,14 @@ Server route failure handling: - Committed regular route errors use `status: 'error'` and later set status code 500. - Lane trimming mirrors client behavior, but without pending/currentness - machinery. + machinery. Controllers for trimmed descendants are aborted; retained match + controllers stay live for request-owned deferred data. +- A redirect publishes no server lane, so every controller in its discarded + candidate lane is aborted before response metadata is returned. +- Redirect resolution can itself fail before `loadServerMatches()` owns a lane + (for example, a route context can throw an unsafe redirect during matching). + The top-level server catch normalizes that resolver failure to the same 500 + response path instead of letting it escape with stale success status. ## Hydration Handoff @@ -1056,13 +1221,19 @@ Hydration flow: dehydrated markers, settle load promises, set `resolvedLocation`, and stop. 12. Otherwise call normal `router.load()` to fill SPA or `ssr: false` holes. +A shorter dehydrated lane is treated as SPA shell mode only when its last +server match is not a terminal error/notFound/global-not-found boundary. Server +loading intentionally trims descendants below those boundaries; hydration +keeps the already-rendered boundary visible while the follow-up client load +replays the same cap instead of replacing it with the shell pending fallback. + That follow-up load replays dehydrated boundaries: when the serial pass in `loadClientMatches()` reaches a dehydrated match whose status is `notFound` -(with a notFound error) or `error`, it treats that server-committed boundary -as the pass's serial failure. The server intentionally omitted every match -below the boundary, so replaying it caps the client lane the same way instead -of loading, committing, and projecting assets for descendants the server never -rendered. +(with a notFound error) or `error`, or a success root match carrying +`globalNotFound`, it treats that server-committed boundary as the pass's serial +failure. The server intentionally omitted every match below the boundary, so +replaying it caps the client lane the same way instead of loading, committing, +and projecting assets for descendants the server never rendered. ```mermaid flowchart TD @@ -1104,6 +1275,10 @@ Foreground client loader reduction: - Redirect throws immediately and wins. - notFound is remembered if no redirect wins. - The first unhandled non-route-control rejection is thrown. +- Before that fatal rejection is rethrown, the shallowest unfinished match is + committed directly as an error fallback and descendants are settled/trimmed. + This defensive path does not start more user error-component work, which + could otherwise hang after the loading machinery itself has failed. - If no fatal rejection wins, notFound commits its boundary or root/global state and trims descendants. - If an error match was committed, descendants after `badIndex` are removed. @@ -1120,11 +1295,16 @@ Server reduction: - Waits for all renderable-prefix promises. - Redirect wins. - Redirects throw before server asset projection. +- Between regular errors and notFounds, the shallower render boundary wins; + an equal-depth regular error keeps ownership. This keeps the response status + aligned with the boundary the framework actually renders. - If no fatal rejection wins, notFound can commit its selected boundary. - Committed route errors trim to `badIndex`, project assets, and then throw the committed `errorMatch.error`. - Unhandled fatal rejections throw after asset projection, but may not have a - committed error match. + committed error match. The top-level server load records these as 500, and + the final committed-match scan cannot overwrite that fatal status with a + concurrent lane outcome. ## Why Lanes Are Private First @@ -1153,7 +1333,8 @@ When changing match loading, check these invariants: - Does every async foreground/background continuation prove currentness before mutating or publishing? -- If a lane can be shortened, are removed matches' load promises settled? +- If a lane can be shortened, are removed matches' load promises settled and + unretained controllers aborted? - Does pending publication remain separate from final commit? - Does route asset projection see the final coherent lane for that path? - Does any client reload path that runs a loader write `loaderData`, including diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index eac88656a2..66eef87e22 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -149,6 +149,14 @@ export interface RouteMatch< dehydrated?: boolean /** Internal loader error used by match loading. */ error?: unknown + /** Controller owned by the loader data currently carried by this match. */ + loaderAbortController?: AbortController + /** Client loader invocation order used to reject stale cache publication. */ + loaderGeneration?: number + /** Development-only route HMR generation used to reject stale reuse. */ + hmrGeneration?: number + /** This private preload lane produced the match's successful loader data. */ + preloadLoaderSuccess?: boolean } loaderData?: TLoaderData /** Internal route context scratch value. */ diff --git a/packages/router-core/src/load-matches.client.ts b/packages/router-core/src/load-matches.client.ts index ba13de6ccd..b32a04904e 100644 --- a/packages/router-core/src/load-matches.client.ts +++ b/packages/router-core/src/load-matches.client.ts @@ -1,7 +1,7 @@ import { createControlledPromise, isPromise } from './utils' import { isNotFound } from './not-found' import { isRedirect } from './redirect' -import { loadRouteChunk } from './route-chunks' +import { loadRouteChunk, routeNeedsPreload } from './route-chunks' import { projectClientRouteAssets } from './route-assets.client' import { commitMatch, @@ -26,6 +26,57 @@ import type { InnerLoadContext, SerialFailure } from './load-matches' const isRouteControl = (error: unknown) => isRedirect(error) || isNotFound(error) +let loaderGeneration = 0 + +const abortUnretainedControllers = ( + discarded: Array, + retainedMatches: Array>, +): void => { + const retained = new Set() + for (const matches of retainedMatches) { + for (const match of matches) { + retained.add(match.abortController) + if (match._.loaderAbortController) { + retained.add(match._.loaderAbortController) + } + } + } + + for (const match of discarded) { + if (!retained.has(match.abortController)) { + match.abortController.abort() + } + const loaderController = match._.loaderAbortController + if (loaderController && !retained.has(loaderController)) { + loaderController.abort() + } + } +} + +/** Abort pass and loader controllers that no longer belong to a published lane. */ +export const abortReplacedMatchControllers = ( + previousMatches: Array, + nextMatches: Array, +): void => { + abortUnretainedControllers(previousMatches, [nextMatches]) +} + +/** Abort discarded private/cache work unless another router lane retained it. */ +export const abortDiscardedMatchControllers = ( + router: AnyRouter, + discarded: Array, +): void => { + const retained = [ + router.stores.matches.get(), + router.stores.pendingMatches.get(), + router.stores.cachedMatches.get(), + ] + for (const lane of router._preloadLanes ?? []) { + retained.push(lane.matches) + } + abortUnretainedControllers(discarded, retained) +} + // Route work may only commit while it still owns the lane entry it is about to // mutate. Each client load pass stamps its matches with an AbortController: // starting a newer load replaces the controller, and cancellation aborts its @@ -56,7 +107,9 @@ const requireCurrentMatch = ( match.abortController !== abortController || abortController.signal.aborted || (pendingLocation && - pendingLocation.publicHref !== inner.location.publicHref) + pendingLocation.publicHref !== inner.location.publicHref && + pendingLocation.publicHref !== + inner.router.stores.location.get().publicHref) ) { throw inner } @@ -122,16 +175,16 @@ const joinPreloadedActiveMatch = async ( // From here the preload lane uses the owner match read-only. It must not clone // or cache a borrowed active match as if the preload generated it itself. inner.matches[index] = match - const error = match._.error ?? match.error - - if (isRouteControl(error)) { - // Borrowed active matches keep ownership of their route outcomes. Preloads - // only observe the active lane; they must not chase a redirect/notFound that - // the foreground navigation is already handling. - throw inner - } - if (match.status !== 'success') { + if ( + match.status !== 'success' || + match.isFetching !== false || + match._.error + ) { + // A successful active snapshot with no local readiness promise but an + // active fetching marker is owned by a private background generation. + // Private match errors are redirects; other route outcomes already carry + // a non-success status. Conservatively abandon either owner shape. throw inner } @@ -168,31 +221,42 @@ const adoptInFlightPreload = async ( continue } const readDonor = () => - lane.matches.find((m) => m.id === match.id && m.preload) + lane.matches.find( + (candidate) => candidate.id === match.id && candidate.preload, + ) let donor = readDonor() if (!donor) { continue } - if (donor.status !== 'success') { - if ( - donor.isFetching !== 'loader' || - donor._.loadPromise?.status !== 'pending' - ) { - // Not joinable (e.g. still in its serial phase) — a later registered - // lane may hold the same match with its loader already in flight. - continue - } - commitMatch(inner, index, { isFetching: 'loader' }) - // The preload pass settles every owned match's loadPromise in its - // finally, so this wait cannot hang even for aborted preloads. - await donor._.loadPromise - requireCurrentMatch(inner, index, passController) - donor = readDonor() + if ( + donor.isFetching !== 'loader' || + donor._.loadPromise?.status !== 'pending' + ) { + // Not joinable (e.g. still in its serial phase) — a later registered + // lane may hold the same match with its loader already in flight. + continue } - - if (donor?.status !== 'success') { - return undefined + commitMatch(inner, index, { isFetching: 'loader' }) + // Cancellation settles the adopter's own readiness without aborting the + // independent donor. Race both so a superseded router.load does not stay + // parked behind a donor preload that is still legitimately in flight. + await Promise.race([donor._.loadPromise, match._.loadPromise!]) + requireCurrentMatch(inner, index, passController) + donor = readDonor() + + if (donor?.status !== 'success' || !donor._.preloadLoaderSuccess) { + // This lane did not produce a successful loader generation (for + // example it redirected, failed, or retained old data after an abort). + // Keep scanning in case a later lane owns a joinable generation. + continue + } + const currentMatch = requireCurrentMatch(inner, index, passController) + currentMatch._.loaderAbortController = + donor._.loaderAbortController ?? donor.abortController + currentMatch._.loaderGeneration = donor._.loaderGeneration + if (inner.preload) { + currentMatch._.preloadLoaderSuccess = true } commitMatch(inner, index, { loaderData: donor.loaderData }) return true @@ -263,6 +327,8 @@ const finalizeRouteFailure = async ( currentMatch = requireCurrentMatch(inner, index, abortController) } + currentMatch._.loaderAbortController = abortController + if (!componentFailure) { if (isRedirect(error)) { currentMatch._.error = error @@ -381,7 +447,8 @@ const handleClientBeforeLoad = ( const existingMatch = inner.matches[index]! const { preload, routeId } = existingMatch - const routeOptions = inner.router.routesById[routeId]!.options + const route = inner.router.routesById[routeId]! + const routeOptions = route.options const abortController = existingMatch.abortController const pending = () => { commitMatch(inner, index, { @@ -397,7 +464,13 @@ const handleClientBeforeLoad = ( ? existingMatch.paramsError : existingMatch.searchError const beforeLoad = routeOptions.beforeLoad - if (beforeLoad || routeOptions.loader || serialError !== undefined) { + if ( + beforeLoad || + routeOptions.loader || + route.lazyFn || + routeNeedsPreload(route) || + serialError !== undefined + ) { existingMatch._.loadPromise ||= createControlledPromise() } @@ -545,6 +618,7 @@ const loadClientRouteMatch = async ( const loader = getLoader(loaderOption) let match = initialMatch + let loaderSucceeded = false const { preload } = match if (match._.dehydrated) { @@ -655,6 +729,7 @@ const loadClientRouteMatch = async ( if (!adopted) { // Kick off the loader! + const generation = loader && ++loaderGeneration const loaderResult = loader?.( getLoaderContext(inner, matchPromises, index, route), ) @@ -670,15 +745,18 @@ const loadClientRouteMatch = async ( ? await loaderResult : loaderResult - requireCurrentMatch(inner, index, passController) + const currentMatch = requireCurrentMatch(inner, index, passController) if (isRouteControl(loaderData)) { throw loaderData } + currentMatch._.loaderAbortController = passController + currentMatch._.loaderGeneration = generation commitMatch(inner, index, { loaderData, }) + loaderSucceeded = true } } } catch (error) { @@ -741,6 +819,10 @@ const loadClientRouteMatch = async ( requireCurrentMatch(inner, index, passController) } + if (loaderSucceeded && inner.preload) { + match._.preloadLoaderSuccess = true + } + commitMatch(inner, index, { status: 'success', error: undefined, @@ -788,6 +870,7 @@ export function startBackgroundLoad( }) const matches = base.slice() + let discardedMatches: Array | undefined let committed = false const cancelBatch = () => { token.controller.abort() @@ -838,14 +921,22 @@ export function startBackgroundLoad( const failures: Array = [] for (const index of indices) { + const abortController = new AbortController() + token.controller.signal.addEventListener( + 'abort', + () => abortController.abort(), + { once: true }, + ) let match = (matches[index] = { ...base[index]!, - abortController: token.controller, + abortController, + _: { ...base[index]!._ }, }) matchPromises[index] = (async (): Promise => { const route = router.routesById[match.routeId]! const loader = getLoader(route.options.loader)! + const generation = ++loaderGeneration try { requireCurrent() @@ -862,6 +953,8 @@ export function startBackgroundLoad( throw loaderData } + match._.loaderAbortController = abortController + match._.loaderGeneration = generation match = { ...match, loaderData, @@ -879,6 +972,7 @@ export function startBackgroundLoad( // eslint-disable-next-line no-ex-assign error = normalizeRouteFailure(inner, index, error) requireCurrent() + match._.loaderAbortController = abortController throw error } @@ -954,6 +1048,7 @@ export function startBackgroundLoad( isFetching: false, updatedAt: Date.now(), } + discardedMatches = matches.slice(index + 1) matches.length = index + 1 } else if (notFoundError) { const index = getNotFoundBoundaryIndex(inner, notFoundError) @@ -981,6 +1076,7 @@ export function startBackgroundLoad( updatedAt: Date.now(), } } + discardedMatches = matches.slice(index + 1) matches.length = index + 1 } @@ -997,13 +1093,20 @@ export function startBackgroundLoad( // loaderData under head output that still describes the previous data. // Keep the old lane; the fetching markers are cleared by the finalizer. if (assetsOk && isCurrentOrCancel()) { + const previousMatches = router.stores.matches.get() router.stores.setMatches(matches) + router._lastFinalMatches = matches + abortReplacedMatchControllers(previousMatches, matches) + if (discardedMatches) { + abortDiscardedMatchControllers(router, discardedMatches) + } committed = true } })().finally(() => { if (router._backgroundLoad === token) { router._backgroundLoad = undefined if (!committed) { + token.controller.abort() clearBackgroundFetching(router) } } @@ -1035,9 +1138,18 @@ export async function loadClientMatches( // assets for descendants the server never rendered. if ( (match.status === 'notFound' && isNotFound(match.error)) || - match.status === 'error' + match.status === 'error' || + (!i && match.globalNotFound) ) { - inner.serialFailure = [i, match.error] + inner.serialFailure = [ + i, + !i && match.globalNotFound + ? ({ + isNotFound: true, + routeId: match.routeId, + } as NotFoundError) + : match.error, + ] break } matchPromises[i] = loadClientRouteMatch(inner, matchPromises, i) @@ -1110,7 +1222,42 @@ export async function loadClientMatches( } if (hasFatalError) { - // Non-route-control failures are fatal to this load call. + // Keep fatal machinery failures globally observable, but do not publish + // an ordinary pending match after its readiness promise was defensively + // settled. Do not enter the normal route-error lifecycle here: loading + // another user component after the machinery already failed could hang + // defensive settlement. Commit the shallowest unfinished route directly + // as the renderable fallback and trim descendants whose work never ran. + const fallbackIndex = inner.matches.findIndex( + (match) => + match.status === 'pending' || + match.isFetching !== false || + match._.loadPromise?.status === 'pending', + ) + if (fallbackIndex >= 0) { + const match = inner.matches[fallbackIndex]! + match._.loaderAbortController = match.abortController + inner.requiresCommit = true + inner.badIndex = Math.min(inner.badIndex ?? fallbackIndex, fallbackIndex) + commitMatch(inner, fallbackIndex, { + status: 'error', + error: fatalError, + isFetching: false, + updatedAt: Date.now(), + context: getMatchContext( + inner, + fallbackIndex, + match.__beforeLoadContext, + ), + }) + finishMatchLoad(inner, fallbackIndex) + while (inner.matches.length > inner.badIndex + 1) { + const discarded = inner.matches.pop()! + settleMatchLoad(discarded) + ;(inner.discardedMatches ||= []).push(discarded) + } + } + throw fatalError } @@ -1123,19 +1270,26 @@ export async function loadClientMatches( // This can differ from the throwing route when routeId targets an ancestor // boundary (or when bubbling resolves to a parent/root boundary). const index = getNotFoundBoundaryIndex(inner, firstNotFound) - if (inner.preload?.includes(inner.matches[index]!.id)) { - return inner.matches - } + // A regular error already committed at an equal or shallower route owns + // the render boundary. A deeper concurrent notFound must not overwrite + // that error or keep descendants below it in the lane. + if (inner.badIndex === undefined || index < inner.badIndex) { + if (inner.preload?.includes(inner.matches[index]!.id)) { + return inner.matches + } - const patch = getNotFoundBoundaryPatch(inner, index, firstNotFound) - commitMatch(inner, index, patch) - finishMatchLoad(inner, index) - trimIndex = index + const patch = getNotFoundBoundaryPatch(inner, index, firstNotFound) + commitMatch(inner, index, patch) + finishMatchLoad(inner, index) + trimIndex = index + } } if (trimIndex !== undefined) { while (inner.matches.length > trimIndex + 1) { - settleMatchLoad(inner.matches.pop()!) + const discarded = inner.matches.pop()! + settleMatchLoad(discarded) + ;(inner.discardedMatches ||= []).push(discarded) } } diff --git a/packages/router-core/src/load-matches.server.ts b/packages/router-core/src/load-matches.server.ts index fc37be640f..e4c1a63f7c 100644 --- a/packages/router-core/src/load-matches.server.ts +++ b/packages/router-core/src/load-matches.server.ts @@ -148,6 +148,7 @@ const runServerBeforeLoad = ( search, abortController, params, + preload: false, context, location: inner.location, navigate: (opts: any) => @@ -242,16 +243,27 @@ const handleServerBeforeLoad = ( })), } - const tempSsr = route.options.ssr(ssrFnContext) - if (isPromise(tempSsr)) { - return tempSsr.then((ssr) => { - existingMatch.ssr = parentOverride(ssr ?? defaultSsr) - return queueServerBeforeLoad() as any - }) as Promise + const applySsr = (ssr: SSROption | undefined) => { + existingMatch.ssr = parentOverride(ssr ?? defaultSsr) + return queueServerBeforeLoad() } - - existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr) - return queueServerBeforeLoad() + let tempSsr + try { + tempSsr = route.options.ssr(ssrFnContext) + } catch (error) { + recordServerBeforeLoadFailure(inner, index, error) + return + } + return isPromise(tempSsr) + ? tempSsr.then( + // The package build's PromiseLike narrowing excludes the private + // `false` serial-failure sentinel even though this function carries it. + applySsr as any, + (error) => { + recordServerBeforeLoadFailure(inner, index, error) + }, + ) + : applySsr(tempSsr) } const getServerLoaderContext = ( @@ -479,20 +491,32 @@ export const loadServerMatches = async ( } if (firstRedirect) { + for (const match of inner.matches) { + match.abortController.abort() + } throw firstRedirect } const notFoundToThrow = firstNotFound const errorIndex = inner.badIndex + let notFoundWins = false if (!hasFatalError && notFoundToThrow) { - const index = commitServerNotFoundBoundary(inner, notFoundToThrow) - while (inner.matches.length > index + 1) { - inner.matches.pop() + const index = getNotFoundBoundaryIndex(inner, notFoundToThrow) + // A regular error already committed at an equal or shallower route owns + // the render boundary. A deeper concurrent notFound must not overwrite + // that error or make the server return 404 for UI that renders as 500. + if (errorIndex == null || index < errorIndex) { + commitServerNotFoundBoundary(inner, notFoundToThrow) + notFoundWins = true + } + const trimIndex = notFoundWins ? index : errorIndex! + while (inner.matches.length > trimIndex + 1) { + inner.matches.pop()!.abortController.abort() } } else if (!hasFatalError) { while (errorIndex != null && inner.matches.length > errorIndex + 1) { - inner.matches.pop() + inner.matches.pop()!.abortController.abort() } } @@ -506,7 +530,7 @@ export const loadServerMatches = async ( throw fatalError } - if (notFoundToThrow) { + if (notFoundWins) { throw notFoundToThrow } diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 4055a57bae..2b3b454e5d 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -39,6 +39,8 @@ export type InnerLoadContext = { background?: Array /** Foreground load must commit even if same-href background reloads exist. */ requiresCommit?: boolean + /** Private matches removed while reducing route failures. */ + discardedMatches?: Array } export type SerialFailure = [index: number, error: unknown] diff --git a/packages/router-core/src/route-assets.client.ts b/packages/router-core/src/route-assets.client.ts index 4c9b53f7b8..6c7b32e0e4 100644 --- a/packages/router-core/src/route-assets.client.ts +++ b/packages/router-core/src/route-assets.client.ts @@ -32,12 +32,9 @@ export function projectClientRouteAssets( preload?: boolean, isCurrent?: () => boolean, startIndex = 0, + clean = true, ): boolean | Promise { for (let i = startIndex; i < matches.length; i++) { - if (isCurrent && !isCurrent()) { - return false - } - const match = matches[i]! if (preload && !match.preload) { continue @@ -47,6 +44,10 @@ export function projectClientRouteAssets( if (!(routeOptions.head || routeOptions.scripts)) { continue } + // Skipped matches execute no user code; callers re-check before publish. + if (isCurrent && !isCurrent()) { + return false + } try { const assetContext = { @@ -94,6 +95,7 @@ export function projectClientRouteAssets( preload, isCurrent, i + 1, + clean, ) return rest }, @@ -109,14 +111,14 @@ export function projectClientRouteAssets( ) } - const rest = projectClientRouteAssets( + return projectClientRouteAssets( router, matches, preload, isCurrent, i + 1, + false, ) - return isPromise(rest) ? rest.then(() => false) : false }, ) } @@ -131,18 +133,11 @@ export function projectClientRouteAssets( console.error(`Error executing head for route ${match.routeId}:`, error) } - // Keep projecting the rest of the lane, but force the overall result - // to false — same shape as the async rejection handler above. - const rest = projectClientRouteAssets( - router, - matches, - preload, - isCurrent, - i + 1, - ) - return isPromise(rest) ? rest.then(() => false) : false + // Keep projecting the rest of the lane, but remember that this pass + // cannot publish atomically even if later hooks succeed. + clean = false } } - return true + return clean } diff --git a/packages/router-core/src/route-assets.server.ts b/packages/router-core/src/route-assets.server.ts index 3bb607b988..1decfc3793 100644 --- a/packages/router-core/src/route-assets.server.ts +++ b/packages/router-core/src/route-assets.server.ts @@ -4,9 +4,9 @@ import type { AnyRouter } from './router' const withServerAssets = ( match: AnyRouteMatch, - head: any, - scripts: any, - headers: any, + head?: any, + scripts?: any, + headers?: any, ): AnyRouteMatch => ({ ...match, meta: head?.meta, @@ -30,6 +30,13 @@ export const projectServerRouteAssets = ( ): void | Promise => { for (let i = startIndex; i < matches.length; i++) { const match = matches[i]! + if (match.ssr === false) { + // A client-only branch contributes no server response assets. Clear + // every field as well as skipping the hooks so a reused match cannot + // leak assets from an earlier SSR-enabled generation. + matches[i] = withServerAssets(match) + continue + } const routeOptions = router.routesById[match.routeId]!.options if (!(routeOptions.head || routeOptions.scripts || routeOptions.headers)) { continue @@ -69,28 +76,37 @@ export const projectServerRouteAssets = ( logAssetError(match, error) } - if (syncFailed && !isPromise(headers)) { + if (syncFailed) { // A sync throw must not hold the response hostage waiting on the // other DECORATIVE hooks' async work: commit sync-available - // head/scripts and abandon pending ones, owning their rejections so - // they cannot become unhandled. When headers() is async the response - // waits for it regardless (headers are response behavior), so that - // case falls through to the generic per-kind branch below, which - // awaits ALL kinds — nothing extra is blocked and a pending head() - // is committed instead of dropped. - const settle = (value: any) => { - if (isPromise(value)) { - void Promise.allSettled([value]) - return undefined + // head/scripts and abandon pending ones, owning their rejections. An + // async headers() remains response-significant, but it is awaited on + // its own so a decorative promise cannot hold the response open. + if (isPromise(head)) { + void head.then( + (value) => (head = value), + (error) => logAssetError(match, error), + ) + head = undefined + } + if (isPromise(scripts)) { + void scripts.then( + (value) => (scripts = value), + (error) => logAssetError(match, error), + ) + scripts = undefined + } + if (isPromise(headers)) { + const commit = (value: any) => { + matches[i] = withServerAssets(match, head, scripts, value) + return projectServerRouteAssets(router, matches, i + 1) } - return value + return headers.then(commit, (error) => { + logAssetError(match, error) + return commit(undefined) + }) } - matches[i] = withServerAssets( - match, - settle(head), - settle(scripts), - headers, - ) + matches[i] = withServerAssets(match, head, scripts, headers) continue } diff --git a/packages/router-core/src/route-chunks.ts b/packages/router-core/src/route-chunks.ts index ea390cefd9..faa4ddacfa 100644 --- a/packages/router-core/src/route-chunks.ts +++ b/packages/router-core/src/route-chunks.ts @@ -61,14 +61,31 @@ export function loadRouteChunk( ) { if (!route._lazyLoaded && !route._lazyPromise) { if (route.lazyFn) { + let lazyGeneration: number | undefined + if (process.env.NODE_ENV !== 'production') { + lazyGeneration = (route._lazyGeneration ?? 0) + 1 + route._lazyGeneration = lazyGeneration + } try { - route._lazyPromise = route.lazyFn().then((lazyRoute) => { - // explicitly don't copy over the lazy route's id - const { id: _id, ...options } = lazyRoute.options - Object.assign(route.options, options) - route._lazyLoaded = true - route._lazyPromise = undefined // gc promise, we won't need it anymore - }) + const lazyPromise = (route._lazyPromise = route + .lazyFn() + .then((lazyRoute) => { + // HMR can retire an in-flight lazy generation by clearing the + // route-level promise. A late result from that generation must not + // overwrite the new route options or mark the replacement loaded. + if ( + process.env.NODE_ENV !== 'production' && + (route._lazyGeneration !== lazyGeneration || + route._lazyPromise !== lazyPromise) + ) { + return + } + // explicitly don't copy over the lazy route's id + const { id: _id, ...options } = lazyRoute.options + Object.assign(route.options, options) + route._lazyLoaded = true + route._lazyPromise = undefined // gc promise, we won't need it anymore + })) } catch (error) { route._lazyPromise = Promise.reject(error) } @@ -109,18 +126,33 @@ export function loadRouteChunk( const componentsPromise = preloads && Promise.all(preloads) if (componentsPromise) { - route._componentsPromise = componentsPromise.then( - () => { - route._componentsLoaded = true - route._componentsPromise = undefined // gc promise, we won't need it anymore - }, - (error) => { - // Clear so a later pass can retry the failed component types; - // successful types stay marked done in the per-type cache. - route._componentsPromise = undefined - throw error - }, - ) + const trackedPromise = (route._componentsPromise = + componentsPromise.then( + () => { + // HMR can replace the route-level preload generation while its + // old component promises are still settling. Only the generation + // still registered on the route may publish completion. + if ( + process.env.NODE_ENV !== 'production' && + route._componentsPromise !== trackedPromise + ) { + return + } + route._componentsLoaded = true + route._componentsPromise = undefined // gc promise, we won't need it anymore + }, + (error) => { + // Clear so a later pass can retry the failed component types; + // successful types stay marked done in the per-type cache. + if ( + process.env.NODE_ENV === 'production' || + route._componentsPromise === trackedPromise + ) { + route._componentsPromise = undefined + } + throw error + }, + )) } else { route._componentsLoaded = true } @@ -128,8 +160,27 @@ export function loadRouteChunk( return route._componentsPromise } - return route._lazyPromise - ? route._lazyPromise.then(runAfterLazy) + const lazyPromise = route._lazyPromise + const lazyGeneration = + process.env.NODE_ENV !== 'production' + ? route._lazyGeneration + : undefined + return lazyPromise + ? lazyPromise.then(() => { + // A retired lazy generation may still resolve after HMR installed a + // replacement promise. Do not let its caller start component work + // from the stale route options. If the replacement already finished, + // runAfterLazy only joins or observes that current component work. + if ( + (process.env.NODE_ENV === 'production' || + route._lazyGeneration === lazyGeneration) && + route._lazyLoaded && + !route._lazyPromise + ) { + return runAfterLazy() + } + return undefined + }) : runAfterLazy() } diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index b7ccba4a5c..442c562571 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -738,6 +738,10 @@ export interface Route< /** @internal */ _lazyPromise?: Promise /** @internal */ + _lazyGeneration?: number + /** @internal Development-only generation advanced by route HMR. */ + _hmrGeneration?: number + /** @internal */ _lazyLoaded?: boolean rank: number to: TrimPathRight @@ -1720,6 +1724,10 @@ export class BaseRoute< /** @internal */ _lazyPromise?: Promise /** @internal */ + _lazyGeneration?: number + /** @internal Development-only generation advanced by route HMR. */ + _hmrGeneration?: number + /** @internal */ _componentsPromise?: Promise /** @internal */ _componentPromises?: Partial< diff --git a/packages/router-core/src/router-load.client.ts b/packages/router-core/src/router-load.client.ts index 314161d2a5..c869618b14 100644 --- a/packages/router-core/src/router-load.client.ts +++ b/packages/router-core/src/router-load.client.ts @@ -7,6 +7,8 @@ import { } from './location-change' import { settleMatchLoad } from './load-matches' import { + abortDiscardedMatchControllers, + abortReplacedMatchControllers, clearBackgroundFetching, loadClientMatches, startBackgroundLoad, @@ -32,15 +34,28 @@ const pushExitingMatch = ( } } +const discardPrivateMatches = ( + router: AnyRouter, + matches: Array, +): void => { + for (const match of matches) { + settleMatchLoad(match) + } + abortDiscardedMatchControllers(router, matches) +} + const commitFinalMatches = ( router: AnyRouter, baseMatches: Array, nextMatches: Array, + removedCachedMatches: Array, + discardedMatches?: Array, ): void => { const { stores } = router + const previousCachedMatches = stores.cachedMatches.get() router.batch(() => { const now = Date.now() - const cached = stores.cachedMatches.get().slice() + const cached = previousCachedMatches.slice() for (let i = 0; i < baseMatches.length; i++) { const match = baseMatches[i]! @@ -77,6 +92,12 @@ const commitFinalMatches = ( stores.loadedAt.set(now) stores.setCached(cached) }) + router._lastFinalMatches = nextMatches + abortDiscardedMatchControllers( + router, + previousCachedMatches.concat(removedCachedMatches, discardedMatches ?? []), + ) + abortReplacedMatchControllers(baseMatches, nextMatches) const matchCount = Math.max(baseMatches.length, nextMatches.length) for (let i = 0; i < matchCount; i++) { @@ -114,14 +135,66 @@ export const loadClientRouter = async ( const historyAction = opts?.action?.type const loadPromise = createControlledPromise() const { stores } = router - - router.latestLoadPromise = loadPromise - + // Seed semantic ownership before this (or a reentrant) pass can publish a + // presentation-only pending lane. This also captures hydrated active state. + router._lastFinalMatches ??= stores.matches.get() + const previousLatestLoadPromise = router.latestLoadPromise const isCurrentLoad = () => router.latestLoadPromise === loadPromise + const settleAndDrain = async () => { + loadPromise.resolve() + let latest = router.latestLoadPromise + while (latest) { + await latest + latest = router.latestLoadPromise + } + } let startedBackgroundLoad = false let loadContext: InnerLoadContext | undefined + // Candidate matching executes fallible user code (loaderDeps/context), and + // context may navigate synchronously. Do not replace the current owner until + // preflight succeeds and proves that no reentrant load took ownership. + let next: typeof router.latestLocation + let pendingMatches: Array + try { + router.updateLatestLocation() + next = router.latestLocation + pendingMatches = router.matchRoutes(next, { + _isCurrent: () => router.latestLoadPromise === previousLatestLoadPromise, + }) + } catch (err) { + if (router.latestLoadPromise === previousLatestLoadPromise) { + if (isRedirect(err)) { + router.navigate({ + ...err.options, + replace: true, + ignoreBlocker: true, + }) + } else { + Promise.reject(err) + if (!previousLatestLoadPromise) { + const commitLocationPromise = router.commitLocationPromise + router.commitLocationPromise = undefined + commitLocationPromise?.resolve() + } + } + } + await settleAndDrain() + return + } + + if (router.latestLoadPromise !== previousLatestLoadPromise) { + discardPrivateMatches(router, pendingMatches) + await settleAndDrain() + return + } + + router.latestLoadPromise = loadPromise + try { + // Rematching is synchronous but user-defined loaderDeps/context work can + // throw. Do not cancel the currently owned generations until a replacement + // lane exists; a failed navigation must leave their resources alive. const backgroundLoad = router._backgroundLoad if (backgroundLoad) { // A foreground navigation supersedes stale same-href background work. @@ -132,27 +205,72 @@ export const loadClientRouter = async ( clearBackgroundFetching(router) } router.cancelMatches() - router.updateLatestLocation() - - const next = router.latestLocation - const pendingMatches = router.matchRoutes(next) + if (!isCurrentLoad()) { + discardPrivateMatches(router, pendingMatches) + await settleAndDrain() + return + } + // A successful preflight may have copied the old pending generation's + // readiness promise. Cancellation settles that owner; the replacement + // generation must create a fresh promise rather than reuse the resolved + // one. Preserve an existing presentation deadline for the same match: its + // fallback stayed visible while ownership changed, so this is still one + // minimum-display session. + for (const match of pendingMatches) { + const previousPromise = match._.loadPromise + if (previousPromise?.status !== 'pending') { + match._.loadPromise = undefined + if (match.status === 'pending' && previousPromise?.pendingUntil) { + match._.loadPromise = createControlledPromise() + match._.loadPromise.pendingUntil = previousPromise.pendingUntil + } + } + } const sameHref = (stores.resolvedLocation.get() ?? stores.location.get()).href === next.href + const previousCachedMatches = stores.cachedMatches.get() router.batch(() => { + for (const match of pendingMatches) { + const activeStore = stores.matchStores.get(match.id) + const activeMatch = activeStore?.get() + if ( + // Only forcePending/same-location replacement can expose fallback UI + // before private pending publication. A cross-location shared root + // receives the private owner through normal pending publication. + // href intentionally ignores a state-only location key change. + stores.location.get().href === next.href && + match.status === 'pending' && + activeMatch?.status === 'pending' + ) { + // forcePending (and an overlapping same-match generation) can make + // the currently rendered match suspend before this private + // replacement is published. Connect only readiness ownership now — + // context/data stay private — so visible UI can arm pendingMin. + const promise = (match._.loadPromise ||= createControlledPromise()) + activeStore!.set({ + ...activeMatch, + _: { + ...activeMatch._, + loadPromise: promise, + }, + }) + } + } stores.status.set('pending') stores.isLoading.set(true) stores.location.set(next) stores.setPending(pendingMatches) stores.setCached( - stores.cachedMatches - .get() - .filter((match) => pendingMatches[match.index]?.id !== match.id), + previousCachedMatches.filter( + (match) => !stores.pendingMatchStores.has(match.id), + ), ) }) + abortDiscardedMatchControllers(router, previousCachedMatches) - const baseMatches = stores.matches.get() + const baseMatches = router._lastFinalMatches if (historyAction) { locationHistoryActions.set(next, historyAction) } else { @@ -182,6 +300,7 @@ export const loadClientRouter = async ( return } + const previousMatches = stores.matches.get() router.batch(() => { // Publication replaces the active lane before final commit, so // exiting success matches must be preserved in the cache here: if @@ -189,10 +308,10 @@ export const loadClientRouter = async ( // that would have cached them never runs, and their fresh data // would otherwise be lost from every pool. let cached: Array | undefined - for (const match of stores.matches.get()) { + for (const match of previousMatches) { if ( match.status === 'success' && - !matches.some((m) => m.id === match.id) + matches[match.index]?.id !== match.id ) { pushExitingMatch( (cached ||= stores.cachedMatches.get().slice()), @@ -208,6 +327,7 @@ export const loadClientRouter = async ( stores.setPending([]) } }) + abortReplacedMatchControllers(previousMatches, matches) } loadContext = { router, @@ -276,13 +396,43 @@ export const loadClientRouter = async ( await assets } if (isCurrentLoad()) { - await router.startViewTransition(async () => { - if (isCurrentLoad()) { + let finalCommitPromise: + | ReturnType> + | undefined + const scheduleFinalCommit = () => { + if (!finalCommitPromise && isCurrentLoad()) { + const promise = (finalCommitPromise = + createControlledPromise()) router.startTransition(() => { - commitFinalMatches(router, baseMatches, pendingMatches) + try { + if (isCurrentLoad()) { + commitFinalMatches( + router, + baseMatches, + pendingMatches, + previousCachedMatches, + loadContext!.discardedMatches, + ) + } + } finally { + promise.resolve() + } }) } - }) + return finalCommitPromise + } + try { + await router.startViewTransition(async () => scheduleFinalCommit()) + } catch (error) { + // A view-transition implementation may reject before it invokes + // its update callback. Navigation ownership still has to commit; + // keep the rejection observable through the outer failure path. + // The shared promise prevents a rejection after the callback (or + // a late callback from a broken wrapper) from committing twice, + // and keeps ownership until a deferred framework transition runs. + await scheduleFinalCommit() + throw error + } } } } @@ -293,6 +443,7 @@ export const loadClientRouter = async ( } catch (err) { if (isCurrentLoad() && isRedirect(err)) { stores.setPending([]) + discardPrivateMatches(router, pendingMatches) router.navigate({ ...err.options, replace: true, @@ -319,25 +470,19 @@ export const loadClientRouter = async ( // Match components throw router.latestLoadPromise for stale pending // snapshots and rely on that ordering to avoid a suspense busy-loop on an // already-resolved load. - loadPromise.resolve() - // A superseded or redirected load must not settle its public await before // the navigation chain does: callers of router.load()/invalidate() rely on // observing post-settlement state, and the preload borrow protocol uses the // foreground load promise as its "committed or gone" signal. // // Termination invariant: latestLoadPromise is only ever installed as a fresh - // promise by a newer pass (top of this function) or cleared to undefined by - // the owning pass in the same sync block that resolves it (above). So after + // promise by a successfully preflighted pass or cleared to undefined by the + // owning pass in the same sync block that resolves it (above). So after // `await latest`, latestLoadPromise is either undefined or a strictly newer // pass's promise — never `latest` (or this pass's loadPromise) again. Any // future writer that resolves latestLoadPromise without replacing/clearing // it would turn this loop into an infinite microtask spin. - let latest = router.latestLoadPromise - while (latest) { - await latest - latest = router.latestLoadPromise - } + await settleAndDrain() if (startedBackgroundLoad) { // Background stale reloads run after the foreground lane is done. If that diff --git a/packages/router-core/src/router-load.server.ts b/packages/router-core/src/router-load.server.ts index c4c331ddce..3234542ed9 100644 --- a/packages/router-core/src/router-load.server.ts +++ b/packages/router-core/src/router-load.server.ts @@ -9,6 +9,7 @@ export const loadServerRouter = async ( opts?: Parameters[0], ): Promise => { let matchedMatches: Array | undefined + let caughtServerError = false try { const next = router.pendingBuiltLocation ?? router.latestLocation router.latestLocation = next @@ -43,9 +44,19 @@ export const loadServerRouter = async ( router.stores.loadedAt.set(Date.now()) router.stores.setMatches(loadedMatches) } catch (err) { - let resolvedRedirect = isRedirect(err) - ? router.resolveRedirect(err) - : undefined + let caughtError = err + let resolvedRedirect: ReturnType | undefined + try { + resolvedRedirect = isRedirect(err) + ? router.resolveRedirect(err) + : undefined + } catch (resolutionError) { + // Matching can throw an unresolved redirect before loadServerMatches + // owns a lane. Normalize a resolver failure through the fatal 500 path + // instead of escaping this catch with stale response status. + caughtError = resolutionError + resolvedRedirect = undefined + } if (resolvedRedirect) { const options = resolvedRedirect.options as any const statusCode = @@ -68,13 +79,19 @@ export const loadServerRouter = async ( router.stores.setMatches(matchedMatches) } - // Committed-match state owns the 404/500 derivation below; here only the - // outcomes it cannot see are recorded (redirect metadata, and a notFound - // that may not have a committed boundary match). + // Redirect/notFound metadata may not have a committed boundary match. + // Any other escaped failure establishes a 500 fallback even when match + // loading failed before it could commit a renderable error outcome. if (resolvedRedirect) { router.statusCode = (resolvedRedirect.options as any).statusCode - } else if (isNotFound(err)) { + } else if (isNotFound(caughtError)) { router.statusCode = 404 + } else { + // Server load machinery can fail before a renderable error match exists + // (for example while resolving a redirect target). Never report that + // failure as a successful response. + router.statusCode = 500 + caughtServerError = true } router.redirect = resolvedRedirect } finally { @@ -91,7 +108,7 @@ export const loadServerRouter = async ( : finalMatches.some((d) => d.status === 'error') ? 500 : undefined - if (newStatusCode) { + if (newStatusCode && !caughtServerError) { router.statusCode = newStatusCode } } diff --git a/packages/router-core/src/router-preload.client.ts b/packages/router-core/src/router-preload.client.ts index c2cf8687aa..ab6c2814ab 100644 --- a/packages/router-core/src/router-preload.client.ts +++ b/packages/router-core/src/router-preload.client.ts @@ -1,9 +1,12 @@ import { isNotFound } from './not-found' import { isRedirect } from './redirect' -import { loadClientMatches } from './load-matches.client' +import { + abortDiscardedMatchControllers, + loadClientMatches, +} from './load-matches.client' import { projectClientRouteAssets } from './route-assets.client' import { settleMatchLoad } from './load-matches' -import { isPromise } from './utils' +import { deepEqual, isPromise } from './utils' import type { AnyRouteMatch } from './Matches' import type { AnyRouter } from './router' import type { InnerLoadContext } from './load-matches' @@ -12,13 +15,24 @@ export const preloadClientRoute = async ( router: AnyRouter, opts: any, ): Promise | undefined> => { + const previousLatestLoadPromise = router.latestLoadPromise const next = opts._builtLocation ?? router.buildLocation(opts) const matches = router.matchRoutes(next, { throwOnError: true, preload: true, + _isCurrent: () => + router.latestLoadPromise === previousLatestLoadPromise, }) + // Route matching can execute user context code. If it synchronously starts + // a navigation, this speculative pass no longer owns the remaining match + // callbacks or any work derived from its partial lane. + if (router.latestLoadPromise !== previousLatestLoadPromise) { + abortDiscardedMatchControllers(router, matches) + return + } + const loadContext: InnerLoadContext = { router, matches, @@ -33,7 +47,43 @@ export const preloadClientRoute = async ( // repeated hovers (and the eventual navigation) can reuse those loads. The // cache invariant still holds — only owned success snapshots enter the // cache; the failed/pending tail never does. - const cacheSuccessfulPrefix = async (): Promise => { + const preloadBorrowsAreCurrent = (): boolean => { + for (const borrowed of matches) { + if (borrowed.preload) { + continue + } + const match = router.getMatch(borrowed.id, false) + if (!match) { + return false + } + if ( + match.abortController !== borrowed.abortController || + borrowed.abortController.signal.aborted + ) { + const routeOptions = router.routesById[borrowed.routeId]!.options + if ( + routeOptions.loader || + routeOptions.beforeLoad || + routeOptions.context || + !deepEqual(match.params, borrowed.params) || + !deepEqual(match.search, borrowed.search) || + !deepEqual(match.context, borrowed.context) + ) { + return false + } + } + if ( + match.status !== 'success' || + match.isFetching !== false || + match._.error + ) { + return false + } + } + return true + } + + const cacheSuccessfulPrefix = async (): Promise => { let prefixEnd = 0 while (matches[prefixEnd]?.status === 'success') { prefixEnd++ @@ -44,37 +94,54 @@ export const preloadClientRoute = async ( const prefix = prefixEnd === matches.length ? matches : matches.slice(0, prefixEnd) - const assets = projectClientRouteAssets(router, prefix, true) + const assets = projectClientRouteAssets( + router, + prefix, + true, + preloadBorrowsAreCurrent, + ) if (isPromise(assets)) { await assets } + if (!preloadBorrowsAreCurrent()) { + return false + } + const previousCachedMatches = router.stores.cachedMatches.get() let ownedMatches: Array | undefined for (const match of prefix) { - // A cache entry this pass borrowed without reloading (same `updatedAt` - // as the cached snapshot) is not owned work: re-caching it would + // A cached snapshot this pass reused without a successful loader + // generation is not owned work: re-caching it would // restamp `preload: true` — silently demoting a navigation entry from - // gcTime to preloadGcTime — while keeping its old `updatedAt`. Only - // snapshots whose loader ran under this pass (fresh `updatedAt`) may - // replace an existing cache entry. + // gcTime to preloadGcTime. Only explicit successful loader generations + // are reusable cache data; loaderless speculative matches stay private. + // Asset projection can finish out of order across concurrent preloads, + // so an older loader generation must not overwrite a newer cached one. if ( - match.preload && !router.getMatch(match.id, false) && - router.getMatch(match.id)?.updatedAt !== match.updatedAt + match._.preloadLoaderSuccess ) { - ;(ownedMatches ||= []).push(match) + const cachedMatch = previousCachedMatches.find( + (candidate) => candidate.id === match.id, + ) + if ( + !cachedMatch || + (cachedMatch._.loaderGeneration ?? 0) <= + match._.loaderGeneration! + ) { + ;(ownedMatches ||= []).push(match) + } } } if (ownedMatches) { router.stores.setCached([ - ...router.stores.cachedMatches - .get() - .filter( - (cachedMatch) => - !ownedMatches.some((match) => match.id === cachedMatch.id), - ), + ...previousCachedMatches.filter( + (cachedMatch) => + !ownedMatches.some((match) => match.id === cachedMatch.id), + ), ...ownedMatches, ]) + abortDiscardedMatchControllers(router, previousCachedMatches) } } @@ -87,7 +154,9 @@ export const preloadClientRoute = async ( // loadClientMatches mutates the lane in place and returns it. await loadClientMatches(loadContext) - await cacheSuccessfulPrefix() + if ((await cacheSuccessfulPrefix()) === false) { + return + } return matches } catch (err) { @@ -123,5 +192,9 @@ export const preloadClientRoute = async ( settleMatchLoad(match) } } + abortDiscardedMatchControllers( + router, + matches.concat(loadContext.discardedMatches ?? []), + ) } } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 62d4e4ae28..b59029a42e 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -38,6 +38,7 @@ import { rootRouteId } from './root' import { isRedirect } from './redirect' import { getLocationChangeInfo } from './location-change' import { getMatchContext, settleMatchLoad } from './load-matches' +import { abortDiscardedMatchControllers } from './load-matches.client' import { preloadClientRoute } from './router-preload.client' import { loadClientRouter } from './router-load.client' import { loadServerRouter } from './router-load.server' @@ -624,6 +625,8 @@ export interface MatchRoutesOpts { preload?: boolean throwOnError?: boolean dest?: BuildNextOptions + /** @internal Stop a load preflight after reentrant work takes ownership. */ + _isCurrent?: () => boolean } export type InferRouterContext = @@ -1295,7 +1298,14 @@ export class RouterCore< emit: EmitFn = (routerEvent) => { this.subscribers.forEach((listener) => { if (listener.eventType === routerEvent.type) { - listener.fn(routerEvent) + try { + listener.fn(routerEvent) + } catch (err) { + // Router events are notifications. A broken observer must not stop + // later observers or interrupt navigation/transition finalization, + // but its failure remains globally observable. + Promise.reject(err) + } } }) } @@ -1429,6 +1439,7 @@ export class RouterCore< ): Array { const throwOnError = opts?.throwOnError const preload = opts?.preload === true + const isCurrent = opts?._isCurrent const matchedRoutesResult = this.getMatchedRoutes(next.pathname) const { foundRoute, routeParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult @@ -1471,229 +1482,283 @@ export class RouterCore< const matches: Array = [] const previousActiveMatches = this.stores.matches.get() const rootContext = this.options.context ?? {} + let hmrGenerationMismatch = false - for (let index = 0; index < matchedRoutes.length; index++) { - const route = matchedRoutes[index]! - const routeOptions = route.options - // Take each matched route and resolve + validate its search params - // This has to happen serially because each route's search params - // can depend on the parent route's search params - // It must also happen before we create the match so that we can - // pass the search params to the route's potential key function - // which is used to uniquely identify the route match in state + try { + for (let index = 0; index < matchedRoutes.length; index++) { + if (isCurrent && !isCurrent()) { + break + } + const route = matchedRoutes[index]! + const routeOptions = route.options + // Take each matched route and resolve + validate its search params + // This has to happen serially because each route's search params + // can depend on the parent route's search params + // It must also happen before we create the match so that we can + // pass the search params to the route's potential key function + // which is used to uniquely identify the route match in state - const parentMatch = matches[index - 1] + const parentMatch = matches[index - 1] - let preMatchSearch: Record - let strictMatchSearch: Record - let searchError: any + let preMatchSearch: Record + let strictMatchSearch: Record + let searchError: any - { - // Validate the search params and stabilize them - const parentSearch = parentMatch?.search ?? next.search - const parentStrictSearch = parentMatch?._strictSearch + { + // Validate the search params and stabilize them + const parentSearch = parentMatch?.search ?? next.search + const parentStrictSearch = parentMatch?._strictSearch - try { - const strictSearch = - validateSearch(routeOptions.validateSearch, { ...parentSearch }) ?? - undefined + try { + const strictSearch = + validateSearch(routeOptions.validateSearch, { + ...parentSearch, + }) ?? undefined + + preMatchSearch = { + ...parentSearch, + ...strictSearch, + } + strictMatchSearch = { ...parentStrictSearch, ...strictSearch } + } catch (err: any) { + let searchParamError = err + if (!(err instanceof SearchParamError)) { + searchParamError = new SearchParamError( + err?.message ?? String(err), + { + cause: err, + }, + ) + } - preMatchSearch = { - ...parentSearch, - ...strictSearch, - } - strictMatchSearch = { ...parentStrictSearch, ...strictSearch } - } catch (err: any) { - let searchParamError = err - if (!(err instanceof SearchParamError)) { - searchParamError = new SearchParamError( - err?.message ?? String(err), - { - cause: err, - }, - ) - } + if (throwOnError) { + throw searchParamError + } - if (throwOnError) { - throw searchParamError + preMatchSearch = parentSearch + strictMatchSearch = {} + searchError = searchParamError } + } - preMatchSearch = parentSearch - strictMatchSearch = {} - searchError = searchParamError + if (isCurrent && !isCurrent()) { + break } - } - // This is where we need to call route.options.loaderDeps() to get any additional - // deps that the route's loader function might need to run. We need to do this - // before we create the match so that we can pass the deps to the route's - // potential key function which is used to uniquely identify the route match in state + // This is where we need to call route.options.loaderDeps() to get any additional + // deps that the route's loader function might need to run. We need to do this + // before we create the match so that we can pass the deps to the route's + // potential key function which is used to uniquely identify the route match in state - const loaderDeps = - routeOptions.loaderDeps?.({ - search: preMatchSearch, - }) ?? '' + const loaderDeps = + routeOptions.loaderDeps?.({ + search: preMatchSearch, + }) ?? '' - const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' + if (isCurrent && !isCurrent()) { + break + } - const { interpolatedPath, usedParams } = interpolatePath({ - path: route.fullPath, - params: routeParams, - decoder: this.pathParamsDecoder, - ...(isServer === undefined ? { server: this.isServer } : undefined), - }) + const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' - // Waste not, want not. If we already have a match for this route, - // reuse it. This is important for layout routes, which might stick - // around between navigation actions that only change leaf routes. + if (isCurrent && !isCurrent()) { + break + } - // Existing matches are matches that are already loaded along with - // pending matches that are still loading - const matchId = - // route.id for disambiguation - route.id + - // interpolatedPath for param changes - interpolatedPath + - // explicit deps - loaderDepsHash + const { interpolatedPath, usedParams } = interpolatePath({ + path: route.fullPath, + params: routeParams, + decoder: this.pathParamsDecoder, + ...(isServer === undefined ? { server: this.isServer } : undefined), + }) - const existingMatch = this.getMatch(matchId) - const previousMatch = - previousActiveMatches[index]?.routeId === route.id - ? previousActiveMatches[index] - : undefined + // Waste not, want not. If we already have a match for this route, + // reuse it. This is important for layout routes, which might stick + // around between navigation actions that only change leaf routes. + + // Existing matches are matches that are already loaded along with + // pending matches that are still loading + const matchId = + // route.id for disambiguation + route.id + + // interpolatedPath for param changes + interpolatedPath + + // explicit deps + loaderDepsHash + + const storedMatch = this.getMatch(matchId) + if ( + process.env.NODE_ENV !== 'production' && + storedMatch && + storedMatch._.hmrGeneration !== (route._hmrGeneration ?? 0) + ) { + // Route context and parsed params are generation-derived. Once a + // hot route changes, its whole descendant suffix must be rebuilt so + // children cannot retain context derived from the old parent. + hmrGenerationMismatch = true + } + // A legacy notFoundRoute is appended after the partially matched + // branch, so the same id can move to a different parent/depth. Its + // cached context belongs to the old parent and cannot be reused. + const existingMatch = + !hmrGenerationMismatch && storedMatch?.index === index + ? storedMatch + : undefined + const previousMatch = + previousActiveMatches[index]?.routeId === route.id + ? previousActiveMatches[index] + : undefined - const strictParams = existingMatch?._strictParams ?? usedParams + const strictParams = existingMatch?._strictParams ?? usedParams - let paramsError: unknown + let paramsError: unknown - if (!existingMatch) { - try { - extractStrictParams(route, strictParams) - } catch (err: any) { - if (isNotFound(err) || isRedirect(err)) { - paramsError = err - } else { - paramsError = new PathParamError(err?.message ?? String(err), { - cause: err, - }) - } + if (!existingMatch) { + try { + extractStrictParams(route, strictParams) + } catch (err: any) { + if (isNotFound(err) || isRedirect(err)) { + paramsError = err + } else { + paramsError = new PathParamError(err?.message ?? String(err), { + cause: err, + }) + } - if (throwOnError) { - throw paramsError + if (throwOnError) { + throw paramsError + } } } - } - Object.assign(routeParams, strictParams) + if (isCurrent && !isCurrent()) { + break + } - const cause = preload ? 'preload' : previousMatch ? 'stay' : 'enter' - const parentContext = parentMatch?.context ?? rootContext - const search = previousMatch - ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) - : existingMatch - ? nullReplaceEqualDeep(existingMatch.search, preMatchSearch) - : preMatchSearch + Object.assign(routeParams, strictParams) + + const cause = preload ? 'preload' : previousMatch ? 'stay' : 'enter' + const parentContext = parentMatch?.context ?? rootContext + const search = previousMatch + ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) + : existingMatch + ? nullReplaceEqualDeep(existingMatch.search, preMatchSearch) + : preMatchSearch + + const abortController = new AbortController() + let match: AnyRouteMatch + + if (existingMatch) { + const loadPromise = existingMatch._.loadPromise + match = { + ...existingMatch, + index, + params: routeParams, + _strictParams: strictParams, + search, + _strictSearch: strictMatchSearch, + searchError, + preload, + cause, + abortController, + // Preserve readiness ownership AND the hydration marker: a + // dehydrated match can legitimately hold a pending loadPromise + // (hydration keeps readiness across rematching), and dropping the + // marker would make the follow-up load re-run its server work. + _: { + loadPromise, + dehydrated: existingMatch._.dehydrated, + loaderAbortController: existingMatch._.loaderAbortController, + loaderGeneration: existingMatch._.loaderGeneration, + }, + } + } else { + const pending = + !(isServer ?? this.isServer) && + (routeOptions.loader || + routeOptions.beforeLoad || + route.lazyFn || + routeNeedsPreload(route)) + + match = { + id: matchId, + ssr: routeOptions.ssr, + index, + routeId: route.id, + params: routeParams, + _strictParams: strictParams, + search, + _strictSearch: strictMatchSearch, + searchError, + preload, + pathname: interpolatedPath, + updatedAt: Date.now(), + status: pending ? 'pending' : 'success', + isFetching: false, + paramsError, + _: pending + ? { + loadPromise: createControlledPromise(), + } + : {}, + abortController, + cause, + loaderDeps, + invalid: false, + staticData: routeOptions.staticData || {}, + fullPath: route.fullPath, + } as AnyRouteMatch + } - const abortController = new AbortController() - let match: AnyRouteMatch + if (process.env.NODE_ENV !== 'production') { + match._.hmrGeneration = route._hmrGeneration ?? 0 + } - if (existingMatch) { - const loadPromise = existingMatch._.loadPromise - match = { - ...existingMatch, - params: routeParams, - _strictParams: strictParams, - search, - _strictSearch: strictMatchSearch, - searchError, - preload, - cause, - abortController, - // Preserve readiness ownership AND the hydration marker: a - // dehydrated match can legitimately hold a pending loadPromise - // (hydration keeps readiness across rematching), and dropping the - // marker would make the follow-up load re-run its server work. - _: { - loadPromise, - dehydrated: existingMatch._.dehydrated, - }, + if (!preload && !match._.dehydrated) { + // If we have a global not found, mark the right match as global not found + match.globalNotFound = globalNotFoundRouteId === route.id } - } else { - const pending = - !(isServer ?? this.isServer) && - (routeOptions.loader || - routeOptions.beforeLoad || - route.lazyFn || - routeNeedsPreload(route)) - - match = { - id: matchId, - ssr: routeOptions.ssr, - index, - routeId: route.id, - params: routeParams, - _strictParams: strictParams, - search, - _strictSearch: strictMatchSearch, - searchError, - preload, - pathname: interpolatedPath, - updatedAt: Date.now(), - status: pending ? 'pending' : 'success', - isFetching: false, - paramsError, - _: pending - ? { - loadPromise: createControlledPromise(), - } - : {}, - abortController, - cause, - loaderDeps, - invalid: false, - staticData: routeOptions.staticData || {}, - fullPath: route.fullPath, - } as AnyRouteMatch - } - if (!preload) { - // If we have a global not found, mark the right match as global not found - match.globalNotFound = globalNotFoundRouteId === route.id - } + matches[index] = match + + if (!existingMatch && routeOptions.context) { + match.__routeContext = routeOptions.context({ + deps: match.loaderDeps, + params: match.params, + context: parentContext, + location: next, + navigate: (opts: any) => + this.navigate({ ...opts, _fromLocation: next }), + buildLocation: this.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload, + matches, + routeId: route.id, + } as RouteContextOptions) + } - matches[index] = match - - if (!existingMatch && routeOptions.context) { - match.__routeContext = routeOptions.context({ - deps: match.loaderDeps, - params: match.params, - context: parentContext, - location: next, - navigate: (opts: any) => - this.navigate({ ...opts, _fromLocation: next }), - buildLocation: this.buildLocation, - cause: match.cause, - abortController: match.abortController, - preload, - matches, - routeId: route.id, - } as RouteContextOptions) + match.context = getMatchContext( + { router: this, matches }, + index, + match.__beforeLoadContext, + ) } - match.context = getMatchContext( - { router: this, matches }, - index, - match.__beforeLoadContext, - ) - } - - for (let i = 0; i < matches.length; i++) { - const previousMatch = previousActiveMatches[i] - const match = matches[i]! - if (previousMatch && previousMatch.routeId === match.routeId) { - match.params = nullReplaceEqualDeep(previousMatch.params, routeParams) + for (let i = 0; i < matches.length; i++) { + const previousMatch = previousActiveMatches[i] + const match = matches[i]! + if (previousMatch && previousMatch.routeId === match.routeId) { + match.params = nullReplaceEqualDeep(previousMatch.params, routeParams) + } + } + } catch (err) { + // This candidate lane never escaped matching. Abort only its fresh pass + // controllers; loaderAbortController may belong to retained cached data. + for (const match of matches) { + match.abortController.abort() } + throw err } return matches @@ -2391,6 +2456,8 @@ export class RouterCore< /** Private in-flight preload lanes, joinable by navigations (client only). */ declare _preloadLanes: Set<{ matches: Array }> | undefined declare _backgroundLoad: BackgroundLoad | undefined + /** Last lane owned by a final/background commit, excluding pending UI publication. */ + declare _lastFinalMatches: Array | undefined load: LoadFn = (opts) => loadRouter(this, opts) @@ -2472,6 +2539,10 @@ export class RouterCore< TDehydrated > > = (opts) => { + const previousMatches = this.stores.matches.get() + // A hydrated client may invalidate before its first normal client load. + // Preserve that terminal lane before forcePending mutates active state. + this._lastFinalMatches ??= previousMatches // `live` gates the only policy difference between the pools: cached // entries stay `status: 'success'` until they are reused, expired, or // explicitly cleared, so only live/pending matches may flip to pending. @@ -2505,7 +2576,14 @@ export class RouterCore< }) this.shouldViewTransition = false - return this.load({ sync: opts?.sync }) + const loadPromise = this.load({ sync: opts?.sync }) + // Client rematching can throw before loadClientRouter raises isLoading. + // Restore the terminal active lane synchronously when that happens so a + // failed forcePending invalidation cannot strand unowned pending UI. + if (!(isServer ?? this.isServer) && !this.stores.isLoading.get()) { + this.stores.setMatches(previousMatches) + } + return loadPromise } resolveRedirect = (redirect: AnyRedirect): AnyRedirect => { @@ -2568,6 +2646,7 @@ export class RouterCore< } this.stores.setCached(nextCachedMatches) + abortDiscardedMatchControllers(this, cachedMatches) } loadRouteChunk = loadRouteChunk diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 296b1c69f9..670c80331c 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -126,12 +126,34 @@ export async function hydrate(router: AnyRouter): Promise { firstNonSsrMatch = match } + if (!dehydratedMatch) { + // The server trimmed this descendant below an error/notFound boundary. + // Its follow-up client load is capped by the dehydrated boundary, so + // loading its normal chunk now would be both unnecessary and unsafe. + return + } + if (match.status === 'error') { + return router.loadRouteChunk(route, 'errorComponent') + } + if (match.status === 'notFound' || match.globalNotFound) { + return router.loadRouteChunk(route, 'notFoundComponent') + } return router.loadRouteChunk(route) }), ) const loadContext = { router, matches } - const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId + const lastDehydratedMatch = + dehydratedRouter.matches[dehydratedRouter.matches.length - 1] + // A terminal boundary can intentionally cap the server lane above client + // matches that still exist for the URL. That is not SPA shell mode: the + // server already rendered the boundary, so hydration must keep it visible + // instead of replacing it with the shell pending fallback. + const isSpaMode = + matches[matches.length - 1]!.id !== lastMatchId && + lastDehydratedMatch?.s !== 'error' && + lastDehydratedMatch?.s !== 'notFound' && + !lastDehydratedMatch?.g let displayMatch = isSpaMode ? matches[1] : firstNonSsrMatch let displayUntil = 0 @@ -170,6 +192,17 @@ export async function hydrate(router: AnyRouter): Promise { router.stores.setMatches(matches) + // Hydration owns the exact match generations it published above. Href alone + // is insufficient: a same-href navigation can replace the lane and later + // clear latestLoadPromise before an old hydration hook resumes (ABA). Every + // lane contains root and every rematch gives it a fresh controller, so root + // is the constant-time generation token for the whole lane. + const hydrationMatch = matches[0]! + const isHydrationCurrent = () => + router.stores.location.get() === location && + router.getMatch(hydrationMatch.id, false)?.abortController === + hydrationMatch.abortController + const clearDisplayPending = () => { let isStillLoadingDisplayMatch = false if (displayMatch) { @@ -209,10 +242,32 @@ export async function hydrate(router: AnyRouter): Promise { return undefined } - const clearAndFailHydration = (err: unknown): never => { + const abandonHydration = (): void => { matches.forEach(settleMatchLoad) - clearDisplayPending() - throw err + if (displayMatch) { + displayMatch._displayPending = undefined + } + } + + const handleHydrationError = (match: AnyRouteMatch, err: unknown): void => { + if (!isHydrationCurrent()) { + return + } + if (isNotFound(err)) { + match.error = { isNotFound: true } + if (process.env.NODE_ENV !== 'production') { + console.error( + `NotFound error during hydration for routeId: ${match.routeId}`, + err, + ) + } + } else { + match.error = err + if (process.env.NODE_ENV !== 'production') { + console.error(`Error during hydration for route ${match.routeId}:`, err) + } + throw err + } } try { @@ -220,61 +275,78 @@ export async function hydrate(router: AnyRouter): Promise { // pending is already published above, so wait here before reconstructing // hydration state from those route options. await routeChunkPromise - } catch (err) { - return clearAndFailHydration(err) - } + if (!isHydrationCurrent()) { + return abandonHydration() + } - // now that all necessary data is hydrated: - // 1) fully reconstruct the route context - // 2) execute `head()` and `scripts()` for each match - try { - await Promise.all( - matches.map(async (match) => { - try { - const route = routesById[match.routeId]! - route.options.ssr = match.ssr - - // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed - // so run it again and merge route context - if (route.options.context) { - match.__routeContext = route.options.context({ - deps: match.loaderDeps, - params: match.params, - context: - matches[match.index - 1]?.context ?? - router.options.context ?? - {}, - location, - navigate: (opts: any) => - router.navigate({ - ...opts, - _fromLocation: location, - }), - buildLocation: router.buildLocation, - cause: match.cause, - abortController: match.abortController, - preload: false, - matches, - routeId: route.id, - } as RouteContextOptions) - } + // Reconstruct every match context before any asset hook runs. An ancestor + // head({ matches }) may inspect a descendant's hydrated beforeLoad context, + // just as it can during server and normal client asset projection. + const hydratedContexts = matches.map((match) => { + try { + if (!isHydrationCurrent()) { + return false + } + const route = routesById[match.routeId]! + route.options.ssr = match.ssr + + // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed + // so run it again and merge route context + if (route.options.context) { + match.__routeContext = route.options.context({ + deps: match.loaderDeps, + params: match.params, + context: + matches[match.index - 1]?.context ?? router.options.context ?? {}, + location, + navigate: (opts: any) => + router.navigate({ + ...opts, + _fromLocation: location, + }), + buildLocation: router.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload: false, + matches, + routeId: route.id, + } as RouteContextOptions) + } - match.context = getMatchContext( - loadContext, - match.index, - match.__beforeLoadContext, - ) - - // The server rendered no assets for ssr:false matches — including - // matches it intentionally omitted at an error/notFound boundary, - // which the dehydrated payload also marks ssr:false. Their - // head()/scripts() could only run with missing or stale loader - // data here; the follow-up client load projects assets for the - // lane it actually commits. - if (match.ssr === false) { - return - } + if (!isHydrationCurrent()) { + return false + } + match.context = getMatchContext( + loadContext, + match.index, + match.__beforeLoadContext, + ) + return true + } catch (err) { + handleHydrationError(match, err) + return false + } + }) + + await Promise.all( + matches.map(async (match, index) => { + if (!hydratedContexts[index] || !isHydrationCurrent()) { + return + } + const route = routesById[match.routeId]! + + // The server rendered no assets for ssr:false matches — including + // matches it intentionally omitted at an error/notFound boundary, + // which the dehydrated payload also marks ssr:false. Their + // head()/scripts() could only run with missing or stale loader + // data here; the follow-up client load projects assets for the + // lane it actually commits. + if (match.ssr === false) { + return + } + + try { const assetContext = { ssr: router.options.ssr, matches, @@ -284,37 +356,37 @@ export async function hydrate(router: AnyRouter): Promise { } const headFnContent = await route.options.head?.(assetContext) + if (!isHydrationCurrent()) { + return + } + const scripts = await route.options.scripts?.(assetContext) + if (!isHydrationCurrent()) { + return + } + match.meta = headFnContent?.meta match.links = headFnContent?.links match.headScripts = headFnContent?.scripts match.styles = headFnContent?.styles match.scripts = scripts } catch (err) { - if (isNotFound(err)) { - match.error = { isNotFound: true } - if (process.env.NODE_ENV !== 'production') { - console.error( - `NotFound error during hydration for routeId: ${match.routeId}`, - err, - ) - } - } else { - match.error = err as any - if (process.env.NODE_ENV !== 'production') { - console.error( - `Error during hydration for route ${match.routeId}:`, - err, - ) - } - throw err - } + handleHydrationError(match, err) } }), ) } catch (err) { - return clearAndFailHydration(err) + if (!isHydrationCurrent()) { + return abandonHydration() + } + matches.forEach(settleMatchLoad) + clearDisplayPending() + throw err + } + + if (!isHydrationCurrent()) { + return abandonHydration() } // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index 45e08ac3b1..6a21223560 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -32,7 +32,6 @@ export type StoreConfig = { createMutableStore: MutableStoreFactory createReadonlyStore: ReadonlyStoreFactory batch: RouterBatchFn - init?: (stores: RouterStores) => void } type MatchStore = RouterWritableStore & { @@ -83,7 +82,6 @@ export interface RouterStores { pendingMatches: ReadableStore> cachedMatches: ReadableStore> firstId: ReadableStore - hasPending: ReadableStore matchRouteDeps: ReadableStore<{ locationHref: string resolvedLocationHref: string | undefined @@ -114,7 +112,7 @@ export function createRouterStores( initialLocation: ParsedLocation>, config: StoreConfig, ): RouterStores { - const { createMutableStore, createReadonlyStore, batch, init } = config + const { createMutableStore, createReadonlyStore, batch } = config // non reactive utilities const matchStores = new Map() @@ -144,12 +142,6 @@ export function createRouterStores( readPoolMatches(cachedMatchStores, cachedIds.get()), ) const firstId = createReadonlyStore(() => matchesId.get()[0]) - const hasPending = createReadonlyStore(() => - matchesId.get().some((matchId) => { - const store = matchStores.get(matchId) - return store?.get().status === 'pending' - }), - ) const matchRouteDeps = createReadonlyStore(() => ({ locationHref: location.get().href, resolvedLocationHref: resolvedLocation.get()?.href, @@ -218,7 +210,6 @@ export function createRouterStores( pendingMatches, cachedMatches, firstId, - hasPending, matchRouteDeps, // non-reactive state @@ -238,8 +229,6 @@ export function createRouterStores( setCached, } as RouterStores - init?.(store as unknown as RouterStores) - // setters to update non-reactive utilities in sync with the reactive stores function setMatches(nextMatches: Array) { reconcileMatchPool( diff --git a/packages/router-core/tests/background-trim-abort.test.ts b/packages/router-core/tests/background-trim-abort.test.ts new file mode 100644 index 0000000000..fcbf281680 --- /dev/null +++ b/packages/router-core/tests/background-trim-abort.test.ts @@ -0,0 +1,125 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('background error trimming aborts a discarded descendant loader signal', async () => { + let parentLoads = 0 + let childLoads = 0 + let retainedParentSignal: AbortSignal | undefined + let discardedChildSignal: AbortSignal | undefined + const parentError = new Error('parent background reload failed') + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: ({ abortController }) => { + if (++parentLoads > 1) { + retainedParentSignal = abortController.signal + throw parentError + } + return 'initial parent data' + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + if (++childLoads > 1) { + discardedChildSignal = abortController.signal + } + return 'child data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + await router.invalidate() + + await expect + .poll(() => router.state.matches.at(-1)?.status) + .toBe('error') + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(retainedParentSignal).toBeDefined() + expect(discardedChildSignal).toBeDefined() + expect(discardedChildSignal).not.toBe(retainedParentSignal) + expect(router.state.matches.at(-1)?.abortController.signal).toBe( + retainedParentSignal, + ) + expect(retainedParentSignal?.aborted).toBe(false) + expect(discardedChildSignal?.aborted).toBe(true) +}) + +test('foreground supersession aborts every loader in a background batch', async () => { + let parentLoads = 0 + let childLoads = 0 + const backgroundSignals: Array = [] + + const waitForAbort = (signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true }, + ) + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: ({ abortController }) => { + if (++parentLoads > 1) { + backgroundSignals.push(abortController.signal) + return waitForAbort(abortController.signal) + } + return 'initial parent data' + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + if (++childLoads > 1) { + backgroundSignals.push(abortController.signal) + return waitForAbort(abortController.signal) + } + return 'initial child data' + }, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + otherRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + const revalidation = router.invalidate() + await vi.waitFor(() => expect(backgroundSignals).toHaveLength(2)) + + await router.navigate({ to: '/other' }) + await revalidation + + expect(backgroundSignals[0]).not.toBe(backgroundSignals[1]) + expect(backgroundSignals.every((signal) => signal.aborted)).toBe(true) + expect(router.state.matches.at(-1)?.routeId).toBe(otherRoute.id) +}) diff --git a/packages/router-core/tests/client-lane-adversarial.test.ts b/packages/router-core/tests/client-lane-adversarial.test.ts new file mode 100644 index 0000000000..4df52c4740 --- /dev/null +++ b/packages/router-core/tests/client-lane-adversarial.test.ts @@ -0,0 +1,545 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, + redirect, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.useRealTimers() +}) + +function abortAwareGate(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true }, + ) + }) +} + +describe('adversarial client lane ownership', () => { + test.each(['onBeforeNavigate', 'onBeforeLoad'] as const)( + 'a throwing %s listener cannot interrupt later listeners or navigation finalization', + async (eventType) => { + const listenerError = new Error(`${eventType} listener failed`) + const laterListener = vi.fn() + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + testCleanups.push(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => 'target data', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + router.subscribe(eventType, () => { + throw listenerError + }) + router.subscribe(eventType, laterListener) + + await router.navigate({ to: '/target' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(laterListener).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + expect(router.stores.isLoading.get()).toBe(false) + expect(unhandledRejection).toHaveBeenCalledWith( + listenerError, + expect.anything(), + ) + }, + ) + + test.each([ + ['normal client commit', false], + ['prepopulated hydration lane', true], + ] as const)( + 'superseding a published pending lane diffs lifecycle hooks from the last final lane (%s)', + async (_name, prepopulateActiveLane) => { + const aOnLeave = vi.fn() + const bOnEnter = vi.fn() + const bOnLeave = vi.fn() + const cOnEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + onLeave: aOnLeave, + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + pendingMs: 0, + pendingComponent: () => null, + loader: ({ abortController }) => abortAwareGate(abortController.signal), + onEnter: bOnEnter, + onLeave: bOnLeave, + }) + const cRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/c', + onEnter: cOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, bRoute, cRoute]), + history: createMemoryHistory({ initialEntries: ['/a'] }), + }) + + if (prepopulateActiveLane) { + // Hydration publishes an already-terminal active lane before the first + // normal client load. The first navigation must seed semantic ownership + // from it before pending presentation can replace the active store. + router.stores.setMatches(router.matchRoutes(router.latestLocation)) + router.stores.resolvedLocation.set(router.latestLocation) + } else { + await router.load() + } + aOnLeave.mockClear() + + const bNavigation = router.navigate({ to: '/b' }) + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: bRoute.id, + status: 'pending', + }), + ) + + await router.navigate({ to: '/c' }) + await bNavigation + + expect(router.state.matches.at(-1)?.routeId).toBe(cRoute.id) + expect(aOnLeave).toHaveBeenCalledTimes(1) + expect(bOnEnter).not.toHaveBeenCalled() + expect(bOnLeave).not.toHaveBeenCalled() + expect(cOnEnter).toHaveBeenCalledTimes(1) + }, + ) + + test('a shallow regular error wins over a deeper concurrent notFound', async () => { + const parentError = new Error('parent failed') + const childHead = vi.fn(() => ({ + meta: [{ title: 'unreachable child' }], + })) + const childOnEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + throw notFound() + }, + notFoundComponent: () => null, + head: childHead, + onEnter: childOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + error: parentError, + }) + expect(childHead).not.toHaveBeenCalled() + expect(childOnEnter).not.toHaveBeenCalled() + }) + + test('a redirect aborts the discarded loader generation before pending UI publishes', async () => { + let redirectSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: ({ abortController }) => { + redirectSignal = abortController.signal + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/source' }) + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)?.routeId).toBe(targetRoute.id) + expect(redirectSignal?.aborted).toBe(true) + }) + + test('trimming a successful descendant aborts its discarded loader generation', async () => { + const parentError = new Error('parent failed') + let childSignal: AbortSignal | undefined + let deferredRequestAborted = false + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + childSignal = abortController.signal + const deferredRequest = new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => { + deferredRequestAborted = true + resolve('aborted') + }, + { once: true }, + ) + }) + return { deferredRequest } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + error: parentError, + }) + expect(childSignal?.aborted).toBe(true) + expect(deferredRequestAborted).toBe(true) + }) + + test('preload trimming aborts a successful descendant owned only by the discarded lane', async () => { + const parentError = new Error('preload parent failed') + let childSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + childSignal = abortController.signal + return { + deferredRequest: new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve('aborted'), + { once: true }, + ) + }), + } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + + expect(childSignal?.aborted).toBe(true) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + }) + + test('navigation started by route context cannot be overwritten by the stale matching pass', async () => { + const bLoaderGate = createControlledPromise() + const bLoader = vi.fn(() => bLoaderGate) + let retainedLoaderSignal: AbortSignal | undefined + let abandonedContextSignal: AbortSignal | undefined + let redirected = false + + const rootRoute = new BaseRootRoute({ + staleTime: Infinity, + loader: ({ abortController }) => { + retainedLoaderSignal = abortController.signal + return 'root data' + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + context: ({ navigate, abortController }) => { + abandonedContextSignal = abortController.signal + if (!redirected) { + redirected = true + void navigate({ to: '/b' }) + } + return { fromA: true } + }, + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + loader: bLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aRoute, bRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const navigation = router.navigate({ to: '/a' }) + await vi.waitFor(() => expect(bLoader).toHaveBeenCalledTimes(1)) + + bLoaderGate.resolve() + await navigation + + expect(router.state.location.pathname).toBe('/b') + expect(router.state.matches.at(-1)?.routeId).toBe(bRoute.id) + expect(abandonedContextSignal?.aborted).toBe(true) + expect(retainedLoaderSignal?.aborted).toBe(false) + }) + + test('a context failure aborts pass controllers from the partial preflight lane', async () => { + const contextError = new Error('context failed after starting work') + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + testCleanups.push(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + let contextSignal: AbortSignal | undefined + let contextWorkAborted = false + + const rootRoute = new BaseRootRoute({}) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + context: ({ abortController }) => { + contextSignal = abortController.signal + abortController.signal.addEventListener( + 'abort', + () => { + contextWorkAborted = true + }, + { once: true }, + ) + throw contextError + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([brokenRoute]), + history: createMemoryHistory({ initialEntries: ['/broken'] }), + }) + + await router.load() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(unhandledRejection).toHaveBeenCalledWith( + contextError, + expect.anything(), + ) + expect(contextSignal?.aborted).toBe(true) + expect(contextWorkAborted).toBe(true) + expect(router.stores.isLoading.get()).toBe(false) + }) + + test('a failed preflight cannot abort and strand the pending lane it attempted to supersede', async () => { + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + testCleanups.push(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + const preflightError = new Error('next loaderDeps failed') + const pendingGate = createControlledPromise() + let pendingSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + }) + const pendingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/pending', + pendingMs: 0, + pendingComponent: () => null, + loader: ({ abortController }) => { + pendingSignal = abortController.signal + return Promise.race([ + pendingGate, + abortAwareGate(abortController.signal), + ]) + }, + }) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + loaderDeps: (): Record => { + throw preflightError + }, + loader: () => 'never runs', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, pendingRoute, brokenRoute]), + history: createMemoryHistory({ initialEntries: ['/a'] }), + }) + + await router.load() + const pendingNavigation = router.navigate({ to: '/pending' }) + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pendingRoute.id, + status: 'pending', + }), + ) + + const brokenNavigation = router.navigate({ to: '/broken' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(unhandledRejection).toHaveBeenCalledWith( + preflightError, + expect.anything(), + ) + expect(pendingSignal?.aborted).toBe(false) + expect(router.stores.isLoading.get()).toBe(true) + + pendingGate.resolve() + await Promise.all([pendingNavigation, brokenNavigation]) + + expect(router.stores.isLoading.get()).toBe(false) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pendingRoute.id, + status: 'success', + }) + }) + + test('forcePending exposes pending route state before pendingMs and settles after the replacement loader', async () => { + const reloadGate = createControlledPromise() + let loaderCalls = 0 + + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMs: 1_000, + pendingComponent: () => null, + loader: async () => { + loaderCalls++ + if (loaderCalls > 1) { + await reloadGate + } + return loaderCalls + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + + let invalidationSettled = false + const invalidation = router.invalidate({ forcePending: true }).then(() => { + invalidationSettled = true + }) + + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'pending', + }), + ) + expect(invalidationSettled).toBe(false) + + reloadGate.resolve() + await invalidation + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + loaderData: 2, + }) + }) +}) diff --git a/packages/router-core/tests/client-route-assets-clean.test.ts b/packages/router-core/tests/client-route-assets-clean.test.ts new file mode 100644 index 0000000000..2d1cc714ff --- /dev/null +++ b/packages/router-core/tests/client-route-assets-clean.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { projectClientRouteAssets } from '../src/route-assets.client' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('client route asset aggregate result', () => { + test.each(['sync throw', 'async rejection'] as const)( + 'keeps a %s failure after a later async head succeeds', + async (failureMode) => { + const failure = new Error('parent head failed') + const childHeadGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const parentHead = vi.fn(() => { + if (failureMode === 'sync throw') { + throw failure + } + return Promise.reject(failure) + }) + const childHead = vi.fn(() => childHeadGate) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + head: parentHead, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + const matches = router.matchRoutes(router.stores.location.get()) + + const projection = projectClientRouteAssets(router, matches) + + await vi.waitFor(() => expect(childHead).toHaveBeenCalledTimes(1)) + expect(parentHead).toHaveBeenCalledTimes(1) + + childHeadGate.resolve({ meta: [{ title: 'child assets' }] }) + + await expect(projection).resolves.toBe(false) + expect( + matches.find((match) => match.routeId === childRoute.id)?.meta, + ).toEqual([{ title: 'child assets' }]) + expect(consoleError).toHaveBeenCalledWith( + `Error executing head for route ${parentRoute.id}:`, + failure, + ) + }, + ) +}) diff --git a/packages/router-core/tests/concurrent-preload-cache-order.test.ts b/packages/router-core/tests/concurrent-preload-cache-order.test.ts new file mode 100644 index 0000000000..fda67fdd71 --- /dev/null +++ b/packages/router-core/tests/concurrent-preload-cache-order.test.ts @@ -0,0 +1,53 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +test('an older preload cannot overwrite a newer preload after slower asset projection', async () => { + const firstHead = createControlledPromise() + const secondHead = createControlledPromise() + let loaderCall = 0 + const projected: Array = [] + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => ++loaderCall, + head: async ({ loaderData }) => { + projected.push(loaderData as number) + await ((loaderData as number) === 1 ? firstHead : secondHead) + return { meta: [{ name: 'revision', content: String(loaderData) }] } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const older = router.preloadRoute({ to: '/target' }) + const newer = router.preloadRoute({ to: '/target' }) + + await vi.waitFor(() => expect(projected).toEqual([1, 2])) + + secondHead.resolve() + await newer + expect( + router.stores.cachedMatches + .get() + .find((match) => match.routeId === targetRoute.id)?.loaderData, + ).toBe(2) + + firstHead.resolve() + await older + + expect( + router.stores.cachedMatches + .get() + .find((match) => match.routeId === targetRoute.id)?.loaderData, + ).toBe(2) +}) diff --git a/packages/router-core/tests/fatal-load-rejection.test.ts b/packages/router-core/tests/fatal-load-rejection.test.ts index aaf8cbb28e..128faa9704 100644 --- a/packages/router-core/tests/fatal-load-rejection.test.ts +++ b/packages/router-core/tests/fatal-load-rejection.test.ts @@ -20,11 +20,18 @@ describe('fatal load rejection', () => { try { const boom = new Error('resolveRedirect failed') + let blockErrorComponentPreload = true + const errorComponentPreload = vi.fn(() => + blockErrorComponentPreload + ? new Promise(() => {}) + : Promise.resolve(), + ) const rootRoute = new BaseRootRoute({ // Never runs: the serial redirect caps the loader prefix at 0. Its // loadPromise (created for the beforeLoad phase) must still settle. loader: () => 'root data', + errorComponent: { preload: errorComponentPreload } as any, }) const badRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -54,6 +61,10 @@ describe('fatal load rejection', () => { await router.load() + // Fatal defensive settlement must not invoke more user route work: a + // never-settling error component cannot hold router.load() open. + expect(errorComponentPreload).not.toHaveBeenCalled() + // The failure stayed observable. await new Promise((resolve) => setTimeout(resolve, 0)) expect(unhandledRejection).toHaveBeenCalledWith(boom, expect.anything()) @@ -66,6 +77,7 @@ describe('fatal load rejection', () => { // A dangling readiness owner would strand framework consumers on the // failed lane. Prove liveness through a normal follow-up navigation // instead of inspecting match-owned promise bookkeeping. + blockErrorComponentPreload = false await router.navigate({ to: '/safe' }) expect(router.state.location.pathname).toBe('/safe') expect(router.state.isLoading).toBe(false) @@ -79,4 +91,55 @@ describe('fatal load rejection', () => { process.off('unhandledRejection', unhandledRejection) } }) + + test('a fatal server redirect-resolution failure returns 500', async () => { + const boom = new Error('server resolveRedirect failed') + const rootRoute = new BaseRootRoute({}) + const badRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bad', + beforeLoad: () => { + throw redirect({ + to: '/bad', + search: () => { + throw boom + }, + }) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([badRoute]), + history: createMemoryHistory({ initialEntries: ['/bad'] }), + isServer: true, + }) + + await router.load() + + expect(router.redirect).toBeUndefined() + expect(router.statusCode).toBe(500) + }) + + test('a server route-context redirect that fails top-level resolution returns 500', async () => { + const rootRoute = new BaseRootRoute({}) + const badRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bad', + context: () => { + // Route context runs during matchRoutes(), before loadServerMatches has + // a lane it can reduce. The top-level redirect resolver must still be + // covered by the server failure/status policy. + throw redirect({ href: 'javascript:alert(1)' }) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([badRoute]), + history: createMemoryHistory({ initialEntries: ['/bad'] }), + isServer: true, + }) + + await expect(router.load()).resolves.toBeUndefined() + + expect(router.redirect).toBeUndefined() + expect(router.statusCode).toBe(500) + }) }) diff --git a/packages/router-core/tests/hydration-asset-context-order.test.ts b/packages/router-core/tests/hydration-asset-context-order.test.ts new file mode 100644 index 0000000000..621869e214 --- /dev/null +++ b/packages/router-core/tests/hydration-asset-context-order.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' + +afterEach(() => { + delete (globalThis as any).window +}) + +test('hydration reconstructs every match context before ancestor head reads the lane', async () => { + let childContextSeenByRootHead: unknown + const rootRoute = new BaseRootRoute({ + head: ({ matches }) => { + childContextSeenByRootHead = matches[1]?.context + return { meta: [{ title: 'hydrated' }] } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad: () => ({ user: 'client fallback' }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + + ;(globalThis as any).window = { + $_TSR: { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: matches.at(-1)!.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + b: + match.routeId === childRoute.id + ? { user: 'server authenticated user' } + : undefined, + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } satisfies TsrSsrGlobal, + } + + await hydrate(router) + + expect(childContextSeenByRootHead).toMatchObject({ + user: 'server authenticated user', + }) +}) diff --git a/packages/router-core/tests/hydration-boundary-chunks.test.ts b/packages/router-core/tests/hydration-boundary-chunks.test.ts new file mode 100644 index 0000000000..089974a549 --- /dev/null +++ b/packages/router-core/tests/hydration-boundary-chunks.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +describe('hydration route chunks below a server boundary', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (global as any).window + }) + + test('hydrates an error boundary without requiring its normal component chunk', async () => { + const serverError = new Error('server beforeLoad failed') + const normalChunkError = new Error('normal component chunk unavailable') + const normalComponentPreload = vi.fn(() => Promise.reject(normalChunkError)) + const errorComponentPreload = vi.fn(() => Promise.resolve()) + const NormalComponent = Object.assign(() => null, { + preload: normalComponentPreload, + }) + const ErrorComponent = Object.assign(() => null, { + preload: errorComponentPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const appRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + beforeLoad: () => { + // This is the server-side failure represented by the dehydrated + // boundary below. Hydration must preserve that committed outcome. + throw serverError + }, + component: NormalComponent, + errorComponent: ErrorComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([appRoute]), + history: createMemoryHistory({ initialEntries: ['/app'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[1]!.id, + matches: [ + { + i: matches[0]!.id, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: matches[1]!.id, + s: 'error', + e: serverError, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await expect(hydrate(router)).resolves.toBeUndefined() + + const appMatch = router.state.matches.find( + (match) => match.routeId === appRoute.id, + ) + expect(appMatch?.status).toBe('error') + expect(appMatch?.error).toBe(serverError) + expect(normalComponentPreload).not.toHaveBeenCalled() + expect(errorComponentPreload).toHaveBeenCalledTimes(1) + }) + + test('hydrates an ancestor notFound boundary without loading an omitted descendant chunk', async () => { + const childChunkError = new Error('omitted child chunk unavailable') + const childComponentPreload = vi.fn(() => Promise.reject(childChunkError)) + const ChildComponent = Object.assign(() => null, { + preload: childComponentPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: () => 'not found', + }) + const serverNotFound = notFound({ routeId: parentRoute.id }) + const childLoader = vi.fn(() => { + // This is the server loader outcome that selected and dehydrated the + // parent boundary. The hydration replay must keep this loader skipped. + throw serverNotFound + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: ChildComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + // The server lane stopped at the parent boundary and never rendered + // or dehydrated the child. + lastMatchId: matches[1]!.id, + matches: [ + { + i: matches[0]!.id, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: matches[1]!.id, + s: 'notFound', + e: serverNotFound, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await expect(hydrate(router)).resolves.toBeUndefined() + + await vi.waitFor(() => { + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + }) + expect(router.state.matches[1]?.status).toBe('notFound') + expect(childComponentPreload).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/router-core/tests/hydration-currentness.test.ts b/packages/router-core/tests/hydration-currentness.test.ts new file mode 100644 index 0000000000..25f4dce9d2 --- /dev/null +++ b/packages/router-core/tests/hydration-currentness.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +/** + * Hydration asset work is another private lane generation. If a public + * navigation commits while an old hydration head is pending, the hydration + * continuation must not execute more old-lane hooks or mark its captured + * location as resolved. + */ +describe('hydration asset currentness', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (global as any).window + }) + + test('does not resume an old hydration lane after a newer navigation commits', async () => { + const oldHeadGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const oldHead = vi.fn(() => oldHeadGate) + const oldScripts = vi.fn(() => [ + { children: 'window.oldHydrationLaneRan = true' }, + ]) + const newHead = vi.fn(() => ({ + meta: [{ title: 'New route' }], + })) + + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + head: oldHead, + scripts: oldScripts, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + head: newHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const oldMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: oldMatches[1]!.id, + matches: oldMatches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + const hydration = hydrate(router) + await vi.waitFor(() => expect(oldHead).toHaveBeenCalledTimes(1)) + + // No framework history subscriber exists during the initial hydration + // handoff, so navigate() exercises its public direct router.load() path. + await router.navigate({ to: '/new' }) + + expect(router.state.location.pathname).toBe('/new') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + newRoute.id, + ]) + const resolvedAfterNavigation = router.state.resolvedLocation?.pathname + expect(resolvedAfterNavigation).not.toBe('/old') + + oldHeadGate.resolve({ meta: [{ title: 'Old route' }] }) + await hydration + + expect({ + oldScriptsCalls: oldScripts.mock.calls.length, + newHeadCalls: newHead.mock.calls.length, + location: router.state.location.pathname, + routeIds: router.state.matches.map((match) => match.routeId), + hydrationMovedResolvedLocationBackToOld: + resolvedAfterNavigation !== '/old' && + router.state.resolvedLocation?.pathname === '/old', + }).toEqual({ + oldScriptsCalls: 0, + newHeadCalls: 1, + location: '/new', + routeIds: [rootRoute.id, newRoute.id], + hydrationMovedResolvedLocationBackToOld: false, + }) + }) + + test('a synchronous context navigation prevents stale hydration assets from running', async () => { + const oldHead = vi.fn(() => ({ meta: [{ title: 'Old route' }] })) + const oldScripts = vi.fn(() => [ + { children: 'window.oldHydrationLaneRan = true' }, + ]) + const newHead = vi.fn(() => ({ meta: [{ title: 'New route' }] })) + + let router!: ReturnType + let navigateDuringHydration = false + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + context: () => { + if (navigateDuringHydration) { + navigateDuringHydration = false + void router.navigate({ to: '/new' }) + } + return { source: 'old' } + }, + head: oldHead, + scripts: oldScripts, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + head: newHead, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const oldMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: oldMatches[1]!.id, + matches: oldMatches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + navigateDuringHydration = true + await hydrate(router) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/new')) + await router.latestLoadPromise + + expect(oldHead).not.toHaveBeenCalled() + expect(oldScripts).not.toHaveBeenCalled() + expect(newHead).toHaveBeenCalledTimes(1) + }) + + test.each(['resolve', 'reject'] as const)( + 'abandons a same-href hydration lane when its old route chunk %ss', + async (chunkSettlement) => { + const routeChunkGate = createControlledPromise() + const routeChunkError = new Error('old hydration chunk failed') + const contextControllers: Array = [] + const headControllers: Array = [] + const scriptsControllers: Array = [] + + const Page = Object.assign(() => null, { + preload: vi.fn(() => routeChunkGate), + }) + const routeContext = vi.fn(({ abortController }) => { + contextControllers.push(abortController) + return { source: 'same-href' } + }) + const head = vi.fn(({ match }) => { + headControllers.push(match.abortController) + return { meta: [{ title: 'Same href' }] } + }) + const scripts = vi.fn(({ match }) => { + scriptsControllers.push(match.abortController) + return [{ children: 'window.sameHrefHydrationRan = true' }] + }) + + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + context: routeContext, + head, + scripts, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const dehydratedMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dehydratedMatches[1]!.id, + matches: dehydratedMatches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + const hydration = hydrate(router) + await vi.waitFor(() => expect(Page.preload).toHaveBeenCalledTimes(1)) + + const hydrationLocation = router.stores.location.get() + const oldMatches = router.stores.matches.get() + const oldRoot = oldMatches[0]! + const oldPage = oldMatches[1]! + + // Ignore context calls made while initially matching the hydration lane. + // Calls observed from here would belong to either the replacement load + // or the stale post-chunk hydration reconstruction. + routeContext.mockClear() + contextControllers.length = 0 + + await router.load() + + const replacementMatches = router.stores.matches.get() + const replacementRoot = replacementMatches[0]! + const replacementPage = replacementMatches[1]! + const resolvedAfterReplacement = router.stores.resolvedLocation.get() + + expect(router.stores.location.get().href).toBe(hydrationLocation.href) + expect(replacementRoot.abortController).not.toBe(oldRoot.abortController) + expect(replacementPage.abortController).not.toBe(oldPage.abortController) + expect(headControllers).toEqual([replacementPage.abortController]) + expect(scriptsControllers).toEqual([replacementPage.abortController]) + expect(contextControllers).not.toContain(oldPage.abortController) + + if (chunkSettlement === 'resolve') { + routeChunkGate.resolve() + } else { + routeChunkGate.reject(routeChunkError) + } + + await expect(hydration).resolves.toBeUndefined() + + expect(router.stores.matches.get()[0]).toBe(replacementRoot) + expect(router.stores.matches.get()[1]).toBe(replacementPage) + expect(replacementRoot.abortController.signal.aborted).toBe(false) + expect(replacementPage.abortController.signal.aborted).toBe(false) + expect(replacementPage.status).toBe('success') + expect(replacementPage.meta).toEqual([{ title: 'Same href' }]) + expect(replacementPage.scripts).toEqual([ + { children: 'window.sameHrefHydrationRan = true' }, + ]) + expect(router.stores.resolvedLocation.get()).toBe( + resolvedAfterReplacement, + ) + expect(headControllers).not.toContain(oldPage.abortController) + expect(scriptsControllers).not.toContain(oldPage.abortController) + expect(contextControllers).not.toContain(oldPage.abortController) + }, + ) +}) diff --git a/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts b/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts new file mode 100644 index 0000000000..7f3bf5f51e --- /dev/null +++ b/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts @@ -0,0 +1,69 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } +}) + +/** + * Fallible rematch work runs before loadClientRouter can raise isLoading. An + * invalidation must keep the old active lane terminal until rematching + * succeeds, so a fatal pre-rematch failure cannot strand unowned pending UI. + */ +test('a fatal pre-rematch failure cannot leave active pending matches while isLoading is false', async () => { + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + testCleanups.push(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + const boom = new Error('loaderDeps failed during invalidation rematch') + let failLoaderDeps = false + let activeSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loaderDeps: () => { + if (failLoaderDeps) { + throw boom + } + return {} + }, + loader: ({ abortController }) => { + activeSignal = abortController.signal + return 'target data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + + await router.load() + expect(router.stores.isLoading.get()).toBe(false) + expect(router.state.matches.at(-1)?.status).toBe('success') + + failLoaderDeps = true + await router.invalidate({ forcePending: true }) + + // loadClientRouter keeps fatal machinery failures observable even though + // its public load/invalidate promise resolves. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unhandledRejection).toHaveBeenCalledWith(boom, expect.anything()) + + expect(router.stores.isLoading.get()).toBe(false) + expect( + router.state.matches.filter((match) => match.status === 'pending'), + ).toEqual([]) + // The failed replacement never acquired a lane, so it must not cancel + // resources owned by the still-active successful loader generation. + expect(activeSignal?.aborted).toBe(false) +}) diff --git a/packages/router-core/tests/issue-4476-preload-navigate-abort.test.ts b/packages/router-core/tests/issue-4476-preload-navigate-abort.test.ts index 8821478db8..c769d198b7 100644 --- a/packages/router-core/tests/issue-4476-preload-navigate-abort.test.ts +++ b/packages/router-core/tests/issue-4476-preload-navigate-abort.test.ts @@ -68,5 +68,10 @@ describe('issue #4476 - navigation adopting an in-flight preload does not abort ) expect(committed?.status).toBe('success') expect(committed?.loaderData).toBe('adopted') + + // Adoption transfers loader-data lifetime ownership to the active match. + // The shared request remains alive while rendered, then aborts on unload. + await router.navigate({ to: '/' }) + expect(capturedSignal?.aborted).toBe(true) }) }) diff --git a/packages/router-core/tests/legacy-not-found-index.test.ts b/packages/router-core/tests/legacy-not-found-index.test.ts new file mode 100644 index 0000000000..11de36e63c --- /dev/null +++ b/packages/router-core/tests/legacy-not-found-index.test.ts @@ -0,0 +1,188 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouteMatch } from '../src' + +function expectLaneIndices(matches: Array) { + expect.soft(matches.map((match) => match.index)).toEqual( + matches.map((_, index) => index), + ) +} + +test('a cached legacy notFoundRoute is reindexed when partial match depth changes', async () => { + const notFoundLoader = vi.fn(() => 'not found') + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + }) + const knownChildRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/known', + }) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/home', + }) + const legacyNotFoundRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/404', + loader: notFoundLoader, + staleTime: Infinity, + gcTime: Infinity, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([knownChildRoute]), + homeRoute, + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/missing'], + }), + notFoundRoute: legacyNotFoundRoute, + }) + + await router.load() + + const deepMatches = router.stores.matches.get() + expect(deepMatches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + legacyNotFoundRoute.id, + ]) + expectLaneIndices(deepMatches) + expect(notFoundLoader).toHaveBeenCalledTimes(1) + + // Leaving the unmatched URL puts the successful notFound loader snapshot + // in the cache at its original, deeper lane position. + await router.navigate({ to: '/home' }) + const cachedNotFound = router.stores.cachedMatches + .get() + .find((match) => match.routeId === legacyNotFoundRoute.id) + expect(cachedNotFound?.index).toBe(2) + + // A shallower unmatched URL appends the same legacy notFoundRoute directly + // below root. Its match id is stable, but the changed parent/depth requires + // a fresh generation whose context and loader run under the new parent. + const next = router.buildLocation({ to: '/missing' } as any) + const matched = router.matchRoutes(next, { preload: true }) + expect(matched.map((match) => match.routeId)).toEqual([ + rootRoute.id, + legacyNotFoundRoute.id, + ]) + expectLaneIndices(matched) + + const preloaded = await router.preloadRoute({ to: '/missing' } as any) + expect(preloaded).toBeDefined() + expectLaneIndices(preloaded!) + expect(notFoundLoader).toHaveBeenCalledTimes(2) + + await router.navigate({ to: '/missing' } as any) + + const activeMatches = router.stores.matches.get() + expect(activeMatches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + legacyNotFoundRoute.id, + ]) + expectLaneIndices(activeMatches) + + // The cache-removal path also keys by match.index. A stale index leaves the + // same match id active and cached at once. + const activeIds = new Set(activeMatches.map((match) => match.id)) + expect( + router.stores.cachedMatches + .get() + .filter((match) => activeIds.has(match.id)), + ).toEqual([]) +}) + +test('a reparented cached legacy notFoundRoute recomputes route context', async () => { + const routeContextBranches: Array = [] + const beforeLoadBranches: Array = [] + const loaderBranches: Array = [] + + const rootRoute = new BaseRootRoute({ + context: () => ({ branch: 'root' }), + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ branch: 'parent' }), + }) + const knownChildRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/known', + }) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/home', + }) + const legacyNotFoundRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/404', + context: ({ context }: any) => { + routeContextBranches.push(context.branch) + return { routeContextBranch: context.branch } + }, + beforeLoad: ({ context }: any) => { + beforeLoadBranches.push(context.routeContextBranch) + return { beforeLoadBranch: context.routeContextBranch } + }, + loader: { + staleReloadMode: 'blocking', + handler: ({ context }: any) => { + loaderBranches.push({ + route: context.routeContextBranch, + beforeLoad: context.beforeLoadBranch, + }) + return context.routeContextBranch + }, + }, + staleTime: 0, + gcTime: Infinity, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([knownChildRoute]), + homeRoute, + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/missing'], + }), + notFoundRoute: legacyNotFoundRoute, + }) + + await router.load() + const deepMatch = router.stores.matches + .get() + .find((match) => match.routeId === legacyNotFoundRoute.id)! + expect(deepMatch.context).toMatchObject({ + branch: 'parent', + routeContextBranch: 'parent', + beforeLoadBranch: 'parent', + }) + + await router.navigate({ to: '/home' }) + await router.navigate({ to: '/missing' } as any) + + const shallowMatch = router.stores.matches + .get() + .find((match) => match.routeId === legacyNotFoundRoute.id)! + expect(shallowMatch.id).toBe(deepMatch.id) + expect(shallowMatch.index).toBe(1) + expect(shallowMatch.context).toMatchObject({ + branch: 'root', + routeContextBranch: 'root', + beforeLoadBranch: 'root', + }) + expect(routeContextBranches).toEqual(['parent', 'root']) + expect(beforeLoadBranches).toEqual(['parent', 'root']) + expect(loaderBranches).toEqual([ + { route: 'parent', beforeLoad: 'parent' }, + { route: 'root', beforeLoad: 'root' }, + ]) +}) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index d7b7bbdbf0..e854835d2a 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -1063,13 +1063,13 @@ describe('beforeLoad skip or exec', () => { }, ) - test('exec if resolved preload (success)', async () => { + test('resolved beforeLoad-only preload stays transient and executes again on navigation', async () => { const beforeLoad = vi.fn() const router = setup({ beforeLoad }) - await router.preloadRoute({ to: '/foo' }) - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) + const preloaded = await router.preloadRoute({ to: '/foo' }) + + expect(preloaded?.some((match) => match.id === '/foo/foo')).toBe(true) + expect(router.stores.cachedMatches.get()).toEqual([]) await sleep(10) await router.navigate({ to: '/foo' }) @@ -6388,11 +6388,7 @@ describe('stale loader reload triggers', () => { await router.load() expect(loader).toHaveBeenCalledTimes(2) - expect(head).not.toHaveBeenCalled() - - await vi.waitFor(() => - expect(getMatchById(router, '/foo/foo')?.status).toBe('notFound'), - ) + expect(getMatchById(router, '/foo/foo')?.status).toBe('notFound') expect(head).toHaveBeenCalledTimes(1) expect(getMatchById(router, '/foo/foo')?.meta).toEqual([ { title: 'not-found' }, @@ -7014,7 +7010,9 @@ describe('stale loader reload triggers', () => { test('soft background AbortError preserves data and updatedAt and clears fetching', async () => { let rejectStaleReload!: (error: unknown) => void let loaderCalls = 0 - const loader = vi.fn(() => { + const loaderSignals: Array = [] + const loader = vi.fn(({ abortController }) => { + loaderSignals.push(abortController.signal) loaderCalls += 1 if (loaderCalls === 1) { return { value: 'old' } @@ -7067,6 +7065,7 @@ describe('stale loader reload triggers', () => { expect(currentMatch.updatedAt).toBe(initialUpdatedAt) expect(getTitle(currentMatch)).toBe('old') expect(head).toHaveBeenCalledTimes(1) + expect(loaderSignals[0]?.aborted).toBe(false) }) test('pending preload error leaves no cache entry', async () => { diff --git a/packages/router-core/tests/preflight-reentrant-context.test.ts b/packages/router-core/tests/preflight-reentrant-context.test.ts new file mode 100644 index 0000000000..5577f356bf --- /dev/null +++ b/packages/router-core/tests/preflight-reentrant-context.test.ts @@ -0,0 +1,148 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('synchronous navigation from route context stops abandoned descendant context work', async () => { + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: ({ navigate }) => { + if (!redirected) { + redirected = true + redirectNavigation = navigate({ to: '/target' }) + } + return { parent: true } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + expect(childContext).not.toHaveBeenCalled() +}) + +test('synchronous navigation from preloaded route context stops abandoned descendant context work', async () => { + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: ({ navigate, preload }) => { + if (preload && !redirected) { + redirected = true + redirectNavigation = navigate({ to: '/target' }) + } + return { parent: true } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + expect(childContext).not.toHaveBeenCalled() +}) + +test('navigation from loaderDeps toJSON stops later abandoned callbacks', async () => { + let router!: ReturnType + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loaderDeps: () => ({ + toJSON: () => { + if (!redirected) { + redirected = true + redirectNavigation = router.navigate({ to: '/target' }) + } + return { key: 'parent' } + }, + }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + expect(childContext).not.toHaveBeenCalled() +}) diff --git a/packages/router-core/tests/preload-adoption-cancellation.test.ts b/packages/router-core/tests/preload-adoption-cancellation.test.ts new file mode 100644 index 0000000000..a1f381b9d4 --- /dev/null +++ b/packages/router-core/tests/preload-adoption-cancellation.test.ts @@ -0,0 +1,54 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +test('superseded router.load stops waiting for a never-settling adopted preload', async () => { + const loaderGate = createControlledPromise() + const loader = vi.fn(() => loaderGate) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute, barRoute]), + history, + }) + + await router.load() + void router.preloadRoute({ to: '/foo' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + history.push('/foo') + let fooSettled = false + const fooLoad = router.load().then(() => { + fooSettled = true + }) + await vi.waitFor(() => { + expect(router.stores.location.get().pathname).toBe('/foo') + expect(router.stores.isLoading.get()).toBe(true) + }) + + history.push('/bar') + await router.load() + expect(router.state.location.pathname).toBe('/bar') + await vi.waitFor(() => expect(fooSettled).toBe(true)) + await fooLoad + expect(loader).toHaveBeenCalledTimes(1) +}) diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts index b3e7bb2126..fad4274b89 100644 --- a/packages/router-core/tests/preload-adoption.test.ts +++ b/packages/router-core/tests/preload-adoption.test.ts @@ -1,18 +1,97 @@ -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' +afterEach(() => { + vi.useRealTimers() +}) + /** * Preload adoption edge cases. The happy path (navigation adopts an * in-flight preload's successful loader run) and the control-flow * non-leakage path are pinned in load.test.ts; this file pins the - * deadlock guard: adoption is gated on the donor's loader being in - * flight, because a preload still in its serial phase can itself be - * waiting on the navigation through the borrow protocol. + * adoption boundary: a donor must have its loader genuinely in flight. A + * preload still in its serial phase can itself be waiting on the navigation + * through the borrow protocol, while a stale successful donor can still have + * fresh loader work pending behind its cached snapshot. */ describe('preload adoption', () => { + test('navigation waits for fresh data from an in-flight stale preload revalidation', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + const revalidationGate = createControlledPromise<{ + notifications: Array + }>() + let loaderCalls = 0 + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const notificationsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/notifications', + staleTime: 0, + preloadStaleTime: 0, + gcTime: 60_000, + loader: { + staleReloadMode: 'blocking', + handler: () => { + loaderCalls++ + if (loaderCalls === 1) { + return { notifications: ['old'] } + } + + return revalidationGate + }, + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, notificationsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/notifications' } as any) + + expect(loaderCalls).toBe(1) + + // The cached successful preload is now stale. A second hover starts a + // genuine revalidation while the user clicks the link. + await vi.advanceTimersByTimeAsync(1) + const revalidation = router.preloadRoute({ + to: '/notifications', + } as any) + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + + let navigationSettled = false + const navigation = router.navigate({ to: '/notifications' }).then(() => { + navigationSettled = true + }) + await Promise.resolve() + + // The navigation may share the revalidation, but it must not treat the + // stale cached snapshot as the completed result while fresh work runs. + expect(navigationSettled).toBe(false) + + revalidationGate.resolve({ notifications: ['fresh'] }) + await Promise.all([revalidation, navigation]) + + // A fix may either share the fresh revalidation or run a navigation + // loader of its own; correctness only requires publishing fresh data. + expect(loaderCalls).toBeGreaterThanOrEqual(2) + expect( + router.state.matches.find( + (match) => match.routeId === notificationsRoute.id, + )?.loaderData, + ).toEqual({ notifications: ['fresh'] }) + }) + test('navigating during the preload serial phase does not deadlock (adoption declined)', async () => { const beforeLoadGate = createControlledPromise() let beforeLoadCalls = 0 @@ -98,6 +177,43 @@ describe('preload adoption', () => { ).toBe('once') }) + test('a fulfilled undefined loader result is adopted as success', async () => { + const loaderGate = createControlledPromise() + const loader = vi.fn(() => loaderGate) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const preload = router.preloadRoute({ to: '/foo' } as any) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + const navigation = router.navigate({ to: '/foo' }) + + loaderGate.resolve(undefined) + await Promise.all([preload, navigation]) + + expect(loader).toHaveBeenCalledTimes(1) + const match = router.state.matches.find( + (candidate) => candidate.routeId === fooRoute.id, + ) + expect(match?.status).toBe('success') + expect(match?.loaderData).toBeUndefined() + }) + test('a non-joinable earlier lane does not hide a later lane with its loader in flight', async () => { const beforeLoadGate = createControlledPromise() let beforeLoadCalls = 0 diff --git a/packages/router-core/tests/preload-background-parent-coherence.test.ts b/packages/router-core/tests/preload-background-parent-coherence.test.ts new file mode 100644 index 0000000000..1293d33653 --- /dev/null +++ b/packages/router-core/tests/preload-background-parent-coherence.test.ts @@ -0,0 +1,87 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A preload that borrows an active parent while that parent is revalidating in + * the background must derive descendant data from the revalidated generation. + * Otherwise the preload can cache a child snapshot based on stale parent data, + * then combine it with the freshly committed parent on navigation. + */ +test('child preload stays coherent with an overlapping parent background reload', async () => { + const backgroundResponse = createControlledPromise<{ revision: number }>() + let parentLoadCount = 0 + + const parentLoader = vi.fn(() => { + parentLoadCount++ + return parentLoadCount === 1 ? { revision: 1 } : backgroundResponse + }) + const childLoader = vi.fn(async ({ parentMatchPromise }) => { + const parentMatch = await parentMatchPromise + return { + parentRevision: (parentMatch.loaderData as { revision: number }).revision, + } + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: Infinity, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + staleTime: Infinity, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + }) + + await router.load() + expect(parentLoader).toHaveBeenCalledTimes(1) + + // Invalidating a successful loader uses the default background revalidation + // mode. The public invalidate promise settles once that private reload has + // started; its network response can remain pending. + await router.invalidate({ + filter: (match) => match.routeId === parentRoute.id, + }) + expect(parentLoader).toHaveBeenCalledTimes(2) + + const childPreload = router.preloadRoute({ to: '/parent/child' }) + + // Let the preload reach the borrowed parent while revision 2 is still in + // flight. This does not require the child to have started: a correct join is + // allowed to wait for the parent response. + await new Promise((resolve) => setTimeout(resolve, 0)) + + backgroundResponse.resolve({ revision: 2 }) + await childPreload + + await vi.waitFor(() => { + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + expect(parentMatch?.loaderData).toEqual({ revision: 2 }) + }) + + await router.navigate({ to: '/parent/child' }) + + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + + expect(parentLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(parentMatch?.loaderData).toEqual({ revision: 2 }) + expect(childMatch?.loaderData).toEqual({ parentRevision: 2 }) +}) diff --git a/packages/router-core/tests/preload-cache-ownership.test.ts b/packages/router-core/tests/preload-cache-ownership.test.ts new file mode 100644 index 0000000000..c7bfee9055 --- /dev/null +++ b/packages/router-core/tests/preload-cache-ownership.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A preload lane may borrow active/cached matches, but borrowed snapshots do + * not become owned merely because their original owner disappears while the + * speculative descendant or asset work is in flight. + */ +describe('preload cache ownership', () => { + test('a descendant preload does not cache data derived from a superseded pending-published parent', async () => { + const currentPageGate = createControlledPromise() + const preloadedPageGate = createControlledPromise() + // This varies only with the requested URL, not with preload mode. For a + // given target it returns exactly the same context to preload/navigation. + const parentBeforeLoad = vi.fn(({ location }) => ({ + workspace: location.pathname, + })) + let preloadedLoaderSignal: AbortSignal | undefined + const currentPageLoader = vi.fn(async ({ abortController }) => { + abortController.signal.addEventListener( + 'abort', + () => currentPageGate.resolve(), + { once: true }, + ) + await currentPageGate + return { page: 'current' } + }) + const preloadedPageLoader = vi.fn(async ({ context, abortController }) => { + preloadedLoaderSignal = abortController.signal + const workspace = (context as { workspace: string }).workspace + await preloadedPageGate + return { workspace } + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/workspace', + beforeLoad: parentBeforeLoad, + }) + const currentPageRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/current', + pendingMs: 0, + pendingComponent: () => null, + loader: currentPageLoader, + }) + const preloadedPageRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/reports', + staleTime: Infinity, + preloadStaleTime: Infinity, + loader: preloadedPageLoader, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([currentPageRoute, preloadedPageRoute]), + otherRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const currentNavigation = router.navigate({ to: '/workspace/current' }) + await vi.waitFor(() => expect(currentPageLoader).toHaveBeenCalledTimes(1)) + + // pendingMs publishes the render-ready lane before its leaf loader + // settles. The parent has already completed and is borrowed as success. + await vi.waitFor(() => { + expect(router.stores.pendingIds.get()).toEqual([]) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.status, + ).toBe('success') + expect( + router.state.matches.find( + (match) => match.routeId === currentPageRoute.id, + )?.status, + ).toBe('pending') + }) + + const reportsPreload = router.preloadRoute({ + to: '/workspace/reports', + }) + await vi.waitFor(() => expect(preloadedPageLoader).toHaveBeenCalledTimes(1)) + + // A fast navigation supersedes and removes the foreground owner. It also + // clears pendingBuiltLocation before the speculative loader responds, + // matching a user who hovers one link and immediately navigates elsewhere. + await router.navigate({ to: '/other' }) + expect(router.state.location.pathname).toBe('/other') + + preloadedPageGate.resolve() + await Promise.allSettled([currentNavigation, reportsPreload]) + + // The reports data was derived from the borrowed /workspace/current + // context. Once that owner was superseded, the speculative snapshot must + // not become a reusable cache entry. + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === preloadedPageRoute.id), + ).toBe(false) + expect(preloadedLoaderSignal?.aborted).toBe(true) + + await router.navigate({ to: '/workspace/reports' }) + + expect(parentBeforeLoad).toHaveBeenCalledTimes(2) + expect(preloadedPageLoader).toHaveBeenCalledTimes(2) + expect( + router.state.matches.find( + (match) => match.routeId === preloadedPageRoute.id, + )?.loaderData, + ).toEqual({ workspace: '/workspace/reports' }) + }) + + test('clearCache during async preload head projection does not resurrect a borrowed cached snapshot', async () => { + const preloadHeadGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + let loaderCalls = 0 + let headCalls = 0 + const reportsLoader = vi.fn(() => ({ revision: ++loaderCalls })) + const reportsHead = vi.fn(() => { + headCalls++ + return headCalls === 1 + ? { meta: [{ title: 'Reports' }] } + : preloadHeadGate + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + staleTime: Infinity, + preloadStaleTime: Infinity, + gcTime: 60_000, + loader: reportsLoader, + head: reportsHead, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/reports' }) + await router.navigate({ to: '/' }) + + expect(reportsLoader).toHaveBeenCalledTimes(1) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === reportsRoute.id), + ).toBe(true) + + // This preload borrows the fresh cached loader result. Its async head + // projection keeps the pass open while the user clears the cache. + const preload = router.preloadRoute({ to: '/reports' }) + await vi.waitFor(() => expect(reportsHead).toHaveBeenCalledTimes(2)) + expect(reportsLoader).toHaveBeenCalledTimes(1) + + router.clearCache() + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === reportsRoute.id), + ).toBe(false) + + preloadHeadGate.resolve({ meta: [{ title: 'Preloaded reports' }] }) + await preload + + // Resolving unrelated asset work cannot turn a borrowed snapshot into + // owned loader work and undo the public clearCache request. + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === reportsRoute.id), + ).toBe(false) + + await router.navigate({ to: '/reports' }) + expect(reportsLoader).toHaveBeenCalledTimes(2) + }) + + test('a borrowed loaderless parent replacement invalidates descendant preload data', async () => { + const reportGate = createControlledPromise() + let reportCalls = 0 + const reportLoader = vi.fn(async ({ parentMatchPromise }) => { + const parentMatch = (await parentMatchPromise) as { + search: { workspace: string } + } + const workspace = parentMatch.search.workspace + if (++reportCalls === 1) { + await reportGate + } + return { workspace } + }) + + const rootRoute = new BaseRootRoute({}) + const workspaceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/workspace', + validateSearch: (search: Record) => ({ + workspace: String(search.workspace ?? ''), + }), + }) + const currentRoute = new BaseRoute({ + getParentRoute: () => workspaceRoute, + path: '/current', + }) + const reportRoute = new BaseRoute({ + getParentRoute: () => workspaceRoute, + path: '/report', + loader: reportLoader, + preloadStaleTime: Infinity, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + workspaceRoute.addChildren([currentRoute, reportRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/workspace/current?workspace=a'], + }), + }) + + await router.load() + const firstParent = router.state.matches.find( + (match) => match.routeId === workspaceRoute.id, + )! + + const preload = router.preloadRoute({ + to: '/workspace/report', + search: { workspace: 'a' }, + } as any) + await vi.waitFor(() => expect(reportLoader).toHaveBeenCalledTimes(1)) + + await router.navigate({ + to: '/workspace/current', + search: { workspace: 'b' }, + } as any) + + const replacementParent = router.state.matches.find( + (match) => match.routeId === workspaceRoute.id, + )! + expect(replacementParent.id).toBe(firstParent.id) + expect(replacementParent.abortController).not.toBe( + firstParent.abortController, + ) + + reportGate.resolve() + await preload + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === reportRoute.id), + ).toBe(false) + + await router.navigate({ + to: '/workspace/report', + search: { workspace: 'b' }, + } as any) + + expect(reportLoader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.loaderData).toEqual({ workspace: 'b' }) + }) +}) diff --git a/packages/router-core/tests/preload-loader-signal-lifetime.test.ts b/packages/router-core/tests/preload-loader-signal-lifetime.test.ts new file mode 100644 index 0000000000..f1b1f7e962 --- /dev/null +++ b/packages/router-core/tests/preload-loader-signal-lifetime.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.useRealTimers() +}) + +function setup(preloadGcTime = 60_000) { + const loaderSignals: Array = [] + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + loaderSignals.push(abortController.signal) + return { + critical: 'reports', + deferredRequest: new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve('aborted'), + { once: true }, + ) + }), + } + }, + ) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + staleTime: Infinity, + preloadStaleTime: Infinity, + preloadGcTime, + loader, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, reportsRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + return { router, reportsRoute, loader, loaderSignals } +} + +describe('preload loader signal lifetime', () => { + test('clearCache aborts a loader signal owned only by the preload cache', async () => { + const { router, reportsRoute, loaderSignals } = setup() + + await router.load() + await router.preloadRoute({ to: '/reports' }) + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === reportsRoute.id), + ).toBe(true) + expect(loaderSignals[0]?.aborted).toBe(false) + + router.clearCache() + + expect(loaderSignals[0]?.aborted).toBe(true) + }) + + test('cache GC aborts an expired preload loader signal', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + + const { router, loaderSignals } = setup(10) + + await router.load() + await router.preloadRoute({ to: '/reports' }) + expect(loaderSignals[0]?.aborted).toBe(false) + + vi.setSystemTime(1_011) + await router.navigate({ to: '/other' }) + + expect(loaderSignals[0]?.aborted).toBe(true) + }) + + test('cache removal for navigation retains the adopted loader signal until unload', async () => { + const { router, loader, loaderSignals } = setup() + + await router.load() + await router.preloadRoute({ to: '/reports' }) + const preloadSignal = loaderSignals[0] + + await router.navigate({ to: '/reports' }) + + expect(loader).toHaveBeenCalledTimes(1) + expect(preloadSignal?.aborted).toBe(false) + + await router.navigate({ to: '/other' }) + + expect(preloadSignal?.aborted).toBe(true) + }) + + test('blocking navigation reload aborts the replaced preload loader signal', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + + const loaderSignals: Array = [] + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + loaderSignals.push(abortController.signal) + return { generation: loaderSignals.length } + }, + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + staleTime: 0, + preloadStaleTime: 0, + loader: { + staleReloadMode: 'blocking', + handler: loader, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/reports' }) + const preloadSignal = loaderSignals[0] + expect(preloadSignal?.aborted).toBe(false) + + vi.setSystemTime(1_001) + await router.navigate({ to: '/reports' }) + + expect(loader).toHaveBeenCalledTimes(2) + expect(preloadSignal?.aborted).toBe(true) + expect(loaderSignals[1]?.aborted).toBe(false) + expect( + router.state.matches.find((match) => match.routeId === reportsRoute.id) + ?.loaderData, + ).toEqual({ generation: 2 }) + }) +}) diff --git a/packages/router-core/tests/preload-updated-at-ownership.test.ts b/packages/router-core/tests/preload-updated-at-ownership.test.ts new file mode 100644 index 0000000000..841ee7ec8c --- /dev/null +++ b/packages/router-core/tests/preload-updated-at-ownership.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.useRealTimers() +}) + +/** + * Cache ownership cannot be inferred from updatedAt alone. Two distinct + * synchronous loader generations can legitimately finish in the same + * millisecond, especially under cached data clients or local loaders. + */ +test('a same-millisecond preload refresh replaces the borrowed navigation cache entry', async () => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + + let revision = 1 + const reportsLoader = vi.fn(() => ({ revision })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + // Navigations keep cached data, while hover preloads explicitly refresh + // it. Both synchronous generations below finish at the same Date.now(). + staleTime: Infinity, + preloadStaleTime: 0, + gcTime: 60_000, + loader: reportsLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/reports' }) + await router.navigate({ to: '/' }) + + expect(reportsLoader).toHaveBeenCalledTimes(1) + expect( + router.stores.cachedMatches + .get() + .find((match) => match.routeId === reportsRoute.id)?.loaderData, + ).toEqual({ revision: 1 }) + + // The preload re-runs synchronously because preloadStaleTime is zero. The + // clock intentionally does not advance, so old and fresh snapshots have + // equal updatedAt values despite coming from different loader generations. + revision = 2 + const preloadedMatches = await router.preloadRoute({ to: '/reports' }) + const preloadedReport = preloadedMatches?.find( + (match) => match.routeId === reportsRoute.id, + ) + + expect(reportsLoader).toHaveBeenCalledTimes(2) + expect(preloadedReport?.loaderData).toEqual({ revision: 2 }) + + // The refreshed snapshot is owned work and must replace the old cache + // entry even though Date.now() could not distinguish the generations. + expect( + router.stores.cachedMatches + .get() + .find((match) => match.routeId === reportsRoute.id)?.loaderData, + ).toEqual({ revision: 2 }) + + await router.navigate({ to: '/reports' }) + + // staleTime=Infinity makes the user-visible navigation consume the cache. + // It must see the fresh preload result without executing a third loader. + expect(reportsLoader).toHaveBeenCalledTimes(2) + expect( + router.state.matches.find((match) => match.routeId === reportsRoute.id) + ?.loaderData, + ).toEqual({ revision: 2 }) +}) diff --git a/packages/router-core/tests/root-beforeload-redirect-lazy.test.ts b/packages/router-core/tests/root-beforeload-redirect-lazy.test.ts new file mode 100644 index 0000000000..af58d8329f --- /dev/null +++ b/packages/router-core/tests/root-beforeload-redirect-lazy.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, redirect } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('root beforeLoad redirect from published pending UI finishes a lazy target load', async () => { + let shouldRedirect = true + + const rootRoute = new BaseRootRoute({ + pendingMs: 0, + pendingComponent: {}, + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + if (shouldRedirect) { + shouldRedirect = false + throw redirect({ to: '/posts' }) + } + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(() => Promise.resolve({ options: {} } as any)) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/posts') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + postsRoute.id, + ]) + expect(router.state.matches.every((match) => match.status === 'success')).toBe( + true, + ) + expect(router.latestLoadPromise).toBeUndefined() + expect(router.stores.isLoading.get()).toBe(false) +}) diff --git a/packages/router-core/tests/server-async-headers-decorative-hang.test.ts b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts new file mode 100644 index 0000000000..831cc8b25d --- /dev/null +++ b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Headers affect the HTTP response, so server loading must await them even + * after another asset hook fails synchronously. That does not make an + * unrelated decorative head promise response-significant: once headers have + * settled, a never-ending head must not hold the response open. + */ +describe('server async headers after a decorative asset failure', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + test('commits async headers without waiting for a pending head', async () => { + const expectedHeaders = { 'x-response-header': 'kept' } + const headGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const headersGate = createControlledPromise() + const head = vi.fn(() => headGate) + const scripts = vi.fn(() => { + throw new Error('scripts failed synchronously') + }) + const headers = vi.fn(() => headersGate) + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + head, + scripts, + headers, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + + const loadPromise = router.load() + await vi.waitFor(() => { + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(headers).toHaveBeenCalledTimes(1) + }) + + headersGate.resolve(expectedHeaders) + + const winnerPromise = Promise.race([ + loadPromise.then(() => 'loaded' as const), + new Promise<'timed-out'>((resolve) => { + setTimeout(() => resolve('timed-out'), 50) + }), + ]) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(50) + const winner = await winnerPromise + + // Cleanup happens only after recording the bounded result. On the broken + // path this releases router.load(), but cannot change `winner`. + headGate.resolve({ meta: [{ title: 'late decorative title' }] }) + await loadPromise + + const targetMatch = router.state.matches.find( + (match) => match.routeId === targetRoute.id, + ) + expect({ winner, headers: targetMatch?.headers }).toEqual({ + winner: 'loaded', + headers: expectedHeaders, + }) + }) +}) diff --git a/packages/router-core/tests/server-beforeload-preload-flag.test.ts b/packages/router-core/tests/server-beforeload-preload-flag.test.ts new file mode 100644 index 0000000000..22509a86c7 --- /dev/null +++ b/packages/router-core/tests/server-beforeload-preload-flag.test.ts @@ -0,0 +1,25 @@ +import { createMemoryHistory } from '@tanstack/history' +import { expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('server beforeLoad receives preload false', async () => { + const seen: Array = [] + const rootRoute = new BaseRootRoute({}) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad: ({ preload }) => { + seen.push(preload) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + await router.load() + + expect(seen).toEqual([false]) +}) diff --git a/packages/router-core/tests/server-concurrent-error-notfound.test.ts b/packages/router-core/tests/server-concurrent-error-notfound.test.ts new file mode 100644 index 0000000000..8127d6fc7d --- /dev/null +++ b/packages/router-core/tests/server-concurrent-error-notfound.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, + redirect, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('server concurrent route failure ordering', () => { + test.each(['parent-first', 'child-first'] as const)( + 'a shallow regular error wins over a deeper notFound (%s)', + async (settleOrder) => { + const parentGate = createControlledPromise() + const childGate = createControlledPromise() + const parentError = new Error('parent loader failed') + let parentSignal: AbortSignal | undefined + let childSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: async ({ abortController }) => { + parentSignal = abortController.signal + await parentGate + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async ({ abortController }) => { + childSignal = abortController.signal + await childGate + throw notFound() + }, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + isServer: true, + }) + + const load = router.load() + if (settleOrder === 'parent-first') { + parentGate.resolve() + await Promise.resolve() + childGate.resolve() + } else { + childGate.resolve() + await Promise.resolve() + parentGate.resolve() + } + await load + + expect(router.statusCode).toBe(500) + expect(router.stores.matches.get().map((match) => match.routeId)).toEqual( + [rootRoute.id, parentRoute.id], + ) + expect(router.stores.matches.get()[1]).toMatchObject({ + status: 'error', + error: parentError, + }) + expect(parentSignal?.aborted).toBe(false) + expect(childSignal?.aborted).toBe(true) + }, + ) + + test.each(['parent-first', 'child-first'] as const)( + 'a shallower notFound boundary wins over a deeper regular error (%s)', + async (settleOrder) => { + const parentGate = createControlledPromise() + const childGate = createControlledPromise() + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: async () => { + await parentGate + throw notFound() + }, + notFoundComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + await childGate + throw new Error('child loader failed') + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + isServer: true, + }) + + const load = router.load() + if (settleOrder === 'parent-first') { + parentGate.resolve() + await Promise.resolve() + childGate.resolve() + } else { + childGate.resolve() + await Promise.resolve() + parentGate.resolve() + } + await load + + expect(router.statusCode).toBe(404) + expect(router.stores.matches.get().map((match) => match.routeId)).toEqual( + [rootRoute.id, parentRoute.id], + ) + expect(router.stores.matches.get()[1]).toMatchObject({ + status: 'notFound', + }) + }, + ) + + test('a redirect aborts every generation in its discarded server lane', async () => { + const signals: Array = [] + const rootRoute = new BaseRootRoute({ + loader: ({ abortController }) => { + signals.push(abortController.signal) + return 'root data' + }, + }) + const redirectRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirect', + loader: ({ abortController }) => { + signals.push(abortController.signal) + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([redirectRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/redirect'] }), + isServer: true, + }) + + await router.load() + + expect(router.redirect).toBeDefined() + expect(signals).toHaveLength(2) + expect(signals.every((signal) => signal.aborted)).toBe(true) + }) +}) diff --git a/packages/router-core/tests/server-ssr-false-assets.test.ts b/packages/router-core/tests/server-ssr-false-assets.test.ts new file mode 100644 index 0000000000..b16d1b6c8f --- /dev/null +++ b/packages/router-core/tests/server-ssr-false-assets.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * `ssr: false` makes a route client-only. Its beforeLoad, loader, component, + * and route assets must all stay out of the server response. Descendants + * inherit that restriction even if they request `ssr: true` themselves. + */ +describe('server assets for ssr:false routes', () => { + test('does not execute or publish assets for the client-only branch', async () => { + const parentHead = vi.fn(() => ({ + meta: [{ title: 'client-only parent' }], + })) + const parentScripts = vi.fn(() => [ + { + children: 'window.parentAssetRan = true', + }, + ]) + const parentHeaders = vi.fn(() => ({ + 'x-client-only-parent': 'unexpected', + })) + const childHead = vi.fn(() => ({ + meta: [{ title: 'client-only child' }], + })) + const childScripts = vi.fn(() => [ + { + children: 'window.childAssetRan = true', + }, + ]) + const childHeaders = vi.fn(() => ({ + 'x-client-only-child': 'unexpected', + })) + + const rootRoute = new BaseRootRoute({}) + const clientOnlyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/client-only', + ssr: false, + head: parentHead, + scripts: parentScripts, + headers: parentHeaders, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => clientOnlyRoute, + path: '/child', + // A child cannot relax its parent's client-only restriction. + ssr: true, + head: childHead, + scripts: childScripts, + headers: childHeaders, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + clientOnlyRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/client-only/child'], + }), + isServer: true, + }) + + await router.load() + + const parentMatch = router.state.matches.find( + (match) => match.routeId === clientOnlyRoute.id, + ) + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + + expect({ + calls: { + parentHead: parentHead.mock.calls.length, + parentScripts: parentScripts.mock.calls.length, + parentHeaders: parentHeaders.mock.calls.length, + childHead: childHead.mock.calls.length, + childScripts: childScripts.mock.calls.length, + childHeaders: childHeaders.mock.calls.length, + }, + matches: [parentMatch, childMatch].map((match) => ({ + ssr: match?.ssr, + meta: match?.meta, + scripts: match?.scripts, + headers: match?.headers, + })), + }).toEqual({ + calls: { + parentHead: 0, + parentScripts: 0, + parentHeaders: 0, + childHead: 0, + childScripts: 0, + childHeaders: 0, + }, + matches: [ + { + ssr: false, + meta: undefined, + scripts: undefined, + headers: undefined, + }, + { + ssr: false, + meta: undefined, + scripts: undefined, + headers: undefined, + }, + ], + }) + }) +}) diff --git a/packages/router-core/tests/server-ssr-option-error.test.ts b/packages/router-core/tests/server-ssr-option-error.test.ts new file mode 100644 index 0000000000..c6696a7487 --- /dev/null +++ b/packages/router-core/tests/server-ssr-option-error.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A functional ssr() option is user route code in the server serial phase. + * If it throws an ordinary error, the route must not be committed as a + * successful 200 response with missing loader data. It follows the same + * renderable route-error lifecycle as beforeLoad and loader failures. + */ +describe('server functional ssr() errors', () => { + test('commits the throwing route as an error and returns 500', async () => { + const boom = new Error('feature flag lookup failed') + const loader = vi.fn(() => 'reports data') + + const rootRoute = new BaseRootRoute({}) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + ssr: () => { + throw boom + }, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/reports'] }), + isServer: true, + }) + + await router.load() + + const reportsMatch = router.state.matches.find( + (match) => match.routeId === reportsRoute.id, + ) + expect(loader).not.toHaveBeenCalled() + expect(router.redirect).toBeUndefined() + expect(router.statusCode).toBe(500) + expect(reportsMatch).toMatchObject({ + status: 'error', + error: boom, + }) + }) +}) diff --git a/packages/router-core/tests/stay-match-abort.test.ts b/packages/router-core/tests/stay-match-abort.test.ts index 7ea7fdae4e..8a93471b1b 100644 --- a/packages/router-core/tests/stay-match-abort.test.ts +++ b/packages/router-core/tests/stay-match-abort.test.ts @@ -1,8 +1,17 @@ -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute } from '../src' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, +} from '../src' import { createTestRouter } from './routerTestUtils' +afterEach(() => { + vi.restoreAllMocks() +}) + /** * A settled success stay-match must keep its abortController un-aborted * across navigations and invalidations: loaders can hand that signal to @@ -12,8 +21,10 @@ import { createTestRouter } from './routerTestUtils' * actively-fetching matches are cancelled at load start. */ -function setup() { +function setup(reloadGate?: Promise) { let capturedSignal: AbortSignal | undefined + let deferredRequestAbortCount = 0 + let loaderCalls = 0 const rootRoute = new BaseRootRoute({}) const dashboardRoute = new BaseRoute({ @@ -21,23 +32,49 @@ function setup() { path: '/dashboard', staleTime: Infinity, loader: ({ abortController }: { abortController: AbortController }) => { + loaderCalls++ capturedSignal = abortController.signal - return 'dashboard data' + + // A common streaming pattern is to return immediately-available data + // alongside a request consumed later by . That request must stay + // alive while this layout is reused, then be cancelled when it unloads. + const deferredRequest = new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => { + deferredRequestAbortCount++ + resolve('aborted') + }, + { once: true }, + ) + }) + + const data = { critical: 'dashboard data', deferredRequest } + return reloadGate && loaderCalls > 1 ? reloadGate.then(() => data) : data }, }) const settingsRoute = new BaseRoute({ getParentRoute: () => dashboardRoute, path: '/settings', }) + const loginRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/login', + }) const router = createTestRouter({ routeTree: rootRoute.addChildren([ dashboardRoute.addChildren([settingsRoute]), + loginRoute, ]), history: createMemoryHistory({ initialEntries: ['/dashboard'] }), }) - return { router, getSignal: () => capturedSignal } + return { + router, + getSignal: () => capturedSignal, + getDeferredRequestAbortCount: () => deferredRequestAbortCount, + } } describe('stay-match abort scope', () => { @@ -51,6 +88,24 @@ describe('stay-match abort scope', () => { expect(getSignal()?.aborted).toBe(false) }) + test('unloading a reused match aborts the signal exposed to its loader', async () => { + const { router, getSignal, getDeferredRequestAbortCount } = setup() + + await router.load() + const dashboardLoaderSignal = getSignal() + + // Reusing the dashboard layout for a child must preserve the lifetime of + // its loader's deferred request. + await router.navigate({ to: '/dashboard/settings' }) + expect(dashboardLoaderSignal?.aborted).toBe(false) + expect(getDeferredRequestAbortCount()).toBe(0) + + // Leaving the layout altogether must now cancel that same request. + await router.navigate({ to: '/login' }) + expect(dashboardLoaderSignal?.aborted).toBe(true) + expect(getDeferredRequestAbortCount()).toBe(1) + }) + test('cancelMatches aborts an active match with an in-flight beforeLoad marker', async () => { // A pending publication can move a lane into the active store while an // ancestor stay match is still mid-beforeLoad (status 'success', @@ -83,14 +138,168 @@ describe('stay-match abort scope', () => { expect(signal.aborted).toBe(true) }) - test('invalidate does not abort a success stay-match signal', async () => { - const { router, getSignal } = setup() + test('background invalidation keeps the old loader signal until fresh data commits', async () => { + const reloadGate = createControlledPromise() + const { router, getSignal } = setup(reloadGate) await router.load() const firstSignal = getSignal() expect(firstSignal?.aborted).toBe(false) - await router.invalidate() + const invalidation = router.invalidate() + await vi.waitFor(() => expect(getSignal()).not.toBe(firstSignal)) + + // The old loader data is still the published generation while the + // background replacement is private. expect(firstSignal?.aborted).toBe(false) + + reloadGate.resolve() + await invalidation + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + }) + + test.each(['beforeLoad', 'shouldReload'] as const)( + '%s failure replacing a successful stay aborts its loader signal', + async (failureStage) => { + const failure = new Error(`${failureStage} failed`) + const onAbort = vi.fn() + let shouldFail = false + let loaderSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + beforeLoad: () => { + if (shouldFail && failureStage === 'beforeLoad') { + throw failure + } + }, + shouldReload: () => { + if (shouldFail && failureStage === 'shouldReload') { + throw failure + } + return false + }, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderSignal = abortController.signal + loaderSignal.addEventListener('abort', onAbort, { once: true }) + return { user: 'Flo' } + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + expect(loaderSignal?.aborted).toBe(false) + + shouldFail = true + await router.load() + + const match = router.state.matches.find( + (candidate) => candidate.routeId === accountRoute.id, + ) + expect(match?.status).toBe('error') + expect(match?.error).toBe(failure) + expect(loaderSignal?.aborted).toBe(true) + expect(onAbort).toHaveBeenCalledTimes(1) + }, + ) + + test.each([false, true])( + 'background notFound replacing successful data aborts its old loader signal (boundary fails: %s)', + async (boundaryFails) => { + let loaderCalls = 0 + let initialSignal: AbortSignal | undefined + const boundaryError = new Error('notFound component failed') + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderCalls++ + if (loaderCalls === 1) { + initialSignal = abortController.signal + return { user: 'Flo' } + } + throw notFound() + }, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + expect(initialSignal?.aborted).toBe(false) + + if (boundaryFails) { + // There is no public route-chunk invalidation API outside the HMR + // integration. Keep this one low-level setup operation local while + // asserting the user-observable error and abort behavior below. + accountRoute.options.notFoundComponent = { + preload: () => { + throw boundaryError + }, + } as any + ;(accountRoute as any)._componentsLoaded = false + } + + await router.invalidate() + await vi.waitFor(() => + expect(router.state.matches.at(-1)?.status).toBe( + boundaryFails ? 'error' : 'notFound', + ), + ) + + if (boundaryFails) { + expect(router.state.matches.at(-1)?.error).toBe(boundaryError) + } + expect(initialSignal?.aborted).toBe(true) + }, + ) + + test('discarded successful background data aborts only its private loader signal', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + let loaderCalls = 0 + const loaderSignals: Array = [] + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderSignals.push(abortController.signal) + return { revision: ++loaderCalls } + }, + head: ({ loaderData }) => { + if (loaderData?.revision === 2) { + throw new Error('fresh head failed') + } + return { meta: [{ title: 'account' }] } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + await router.invalidate() + + expect(router.state.matches.at(-1)?.loaderData).toEqual({ revision: 1 }) + expect(loaderSignals).toHaveLength(2) + expect(loaderSignals[0]?.aborted).toBe(false) + expect(loaderSignals[1]?.aborted).toBe(true) }) }) diff --git a/packages/router-core/tests/view-transition-commit-failure.test.ts b/packages/router-core/tests/view-transition-commit-failure.test.ts new file mode 100644 index 0000000000..abe8c280d6 --- /dev/null +++ b/packages/router-core/tests/view-transition-commit-failure.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('view-transition final commit failure', () => { + const unhandledRejections = new Set() + const onUnhandledRejection = (error: unknown) => { + unhandledRejections.add(error) + } + + afterEach(() => { + process.off('unhandledRejection', onUnhandledRejection) + unhandledRejections.clear() + vi.restoreAllMocks() + }) + + test('a wrapper rejection before its callback still commits the destination and cleans loading state', async () => { + const failure = new Error('view transition rejected before update') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const destinationRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/destination', + loader: () => 'destination data', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, destinationRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + process.on('unhandledRejection', onUnhandledRejection) + // Solid-style framework transitions may defer the commit callback. A + // failed view-transition fallback must publish before the router clears + // this load's ownership, rather than enqueue work that currentness will + // correctly reject one microtask later. + router.startTransition = (update) => { + queueMicrotask(update) + } + router.startViewTransition = vi.fn(async () => { + throw failure + }) + + await router.navigate({ to: '/destination', viewTransition: true }) + + expect(router.state.location.pathname).toBe('/destination') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + destinationRoute.id, + ]) + expect(router.state.matches.at(-1)?.loaderData).toBe('destination data') + expect(router.stores.isLoading.get()).toBe(false) + expect(router.stores.pendingMatches.get()).toEqual([]) + await vi.waitFor(() => expect(unhandledRejections).toContain(failure)) + }) + + test('a wrapper rejection after its callback does not commit the destination twice', async () => { + const failure = new Error('view transition rejected after update') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const destinationRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/destination', + loader: () => 'destination data', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, destinationRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const setMatches = vi.spyOn(router.stores, 'setMatches') + process.on('unhandledRejection', onUnhandledRejection) + router.startViewTransition = vi.fn(async (update) => { + await update() + throw failure + }) + + await router.navigate({ to: '/destination', viewTransition: true }) + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + destinationRoute.id, + ]) + expect(router.stores.isLoading.get()).toBe(false) + expect(router.stores.pendingMatches.get()).toEqual([]) + expect(setMatches).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(unhandledRejections).toContain(failure)) + }) +}) diff --git a/packages/router-plugin/src/core/hmr/handle-route-update.ts b/packages/router-plugin/src/core/hmr/handle-route-update.ts index c844197b03..d031339b05 100644 --- a/packages/router-plugin/src/core/hmr/handle-route-update.ts +++ b/packages/router-plugin/src/core/hmr/handle-route-update.ts @@ -15,7 +15,11 @@ type AnyRouteWithPrivateProps = AnyRoute & { Promise | undefined > > + _componentsLoaded?: boolean _lazyPromise?: Promise + _lazyGeneration?: number + _hmrGeneration?: number + _lazyLoaded?: boolean update: (options: Record) => unknown _path: string _id: string @@ -114,15 +118,21 @@ function handleRouteUpdate( oldRoute.update(nextOptions) oldRoute._componentsPromise = undefined oldRoute._componentPromises = undefined + oldRoute._componentsLoaded = false oldRoute._lazyPromise = undefined + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1 + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1 + oldRoute._lazyLoaded = false router.setRoutes(router.buildRouteTree()) syncHotRouteExport(oldRoute) router.resolvePathCache.clear() const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id - const activeMatch = router.stores.matches.get().find(filter) - const pendingMatch = router.stores.pendingMatches.get().find(filter) + const activeMatches = router.stores.matches.get() + const pendingMatches = router.stores.pendingMatches.get() + const activeMatch = activeMatches.find(filter) + const pendingMatch = pendingMatches.find(filter) const cachedMatches = router.stores.cachedMatches.get().filter(filter) if (activeMatch || pendingMatch || cachedMatches.length > 0) { @@ -133,18 +143,28 @@ function handleRouteUpdate( // // We update the store directly so the clear is visible before invalidate // reads the store and rematches the route. - if (removedKeys.has('loader') || removedKeys.has('beforeLoad')) { - const matchIds = [ - activeMatch?.id, - pendingMatch?.id, - ...cachedMatches.map((match) => match.id), - ].filter(Boolean) as Array + const projectedAssetsRemoved = + removedKeys.has('head') || removedKeys.has('scripts') + if ( + removedKeys.has('loader') || + removedKeys.has('beforeLoad') || + projectedAssetsRemoved + ) { + const liveStores = [ + activeMatch && + ([ + router.stores.matchStores.get(activeMatch.id), + activeMatches, + ] as const), + pendingMatch && + ([ + router.stores.pendingMatchStores.get(pendingMatch.id), + pendingMatches, + ] as const), + ] router.batch(() => { - for (const matchId of matchIds) { - const store = - router.stores.pendingMatchStores.get(matchId) || - router.stores.matchStores.get(matchId) || - router.stores.cachedMatchStores.get(matchId) + for (const live of liveStores) { + const store = live?.[0] if (store) { store.set((prev) => { const next: AnyRouteMatchWithPrivateProps = { ...prev } @@ -154,7 +174,17 @@ function handleRouteUpdate( } if (removedKeys.has('beforeLoad')) { next.__beforeLoadContext = undefined - next.context = rebuildMatchContextWithoutBeforeLoad(next) + next.context = rebuildMatchContextWithoutBeforeLoad( + next, + live[1], + ) + } + if (projectedAssetsRemoved) { + next.meta = undefined + next.links = undefined + next.headScripts = undefined + next.scripts = undefined + next.styles = undefined } return next @@ -162,6 +192,14 @@ function handleRouteUpdate( } } }) + + // Cached matches are a flat pool rather than an ordered route lane, so + // their parent context cannot be reconstructed positionally. Their + // projected assets also cannot be recomputed until a later visit. + // Discard entries whose hot route contract changed and rematch later. + if (cachedMatches.length > 0) { + router.clearCache({ filter }) + } } router.invalidate({ filter, sync: true }) @@ -179,59 +217,12 @@ function handleRouteUpdate( newRoute._to = liveRoute._to } - function getStoreMatch(matchId: string) { - return ( - router.stores.pendingMatchStores.get(matchId)?.get() || - router.stores.matchStores.get(matchId)?.get() || - router.stores.cachedMatchStores.get(matchId)?.get() - ) - } - - function getMatchList(matchId: string) { - const pendingMatches = router.stores.pendingMatches.get() - if (pendingMatches.some((match) => match.id === matchId)) { - return pendingMatches - } - - const activeMatches = router.stores.matches.get() - if (activeMatches.some((match) => match.id === matchId)) { - return activeMatches - } - - const cachedMatches = router.stores.cachedMatches.get() - if (cachedMatches.some((match) => match.id === matchId)) { - return cachedMatches - } - - return [] - } - - function getParentMatch(match: AnyRouteMatch) { - const matchList = getMatchList(match.id) - const matchIndex = matchList.findIndex((item) => item.id === match.id) - - if (matchIndex <= 0) { - return undefined - } - - const parentMatch = matchList[matchIndex - 1]! - return getStoreMatch(parentMatch.id) || parentMatch - } - function rebuildMatchContextWithoutBeforeLoad( match: AnyRouteMatchWithPrivateProps, + lane: Array, ) { - const parentMatch = getParentMatch(match) - const getParentContext = ( - router as unknown as { - getParentContext?: ( - parentMatch?: AnyRouteMatch, - ) => Record | undefined - } - ).getParentContext - const parentContext = getParentContext - ? getParentContext.call(router, parentMatch) - : (parentMatch?.context ?? router.options.context) + const parentContext = + lane[match.index - 1]?.context ?? router.options.context return { ...(parentContext ?? {}), diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index 578b28f7b5..6c394c82f1 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -79,20 +79,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -104,7 +111,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -112,6 +127,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -128,41 +148,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx index 12dec482ce..d354a85d80 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx @@ -65,20 +65,27 @@ if (import.meta.webpackHot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -90,7 +97,15 @@ if (import.meta.webpackHot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -98,6 +113,11 @@ if (import.meta.webpackHot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -114,41 +134,8 @@ if (import.meta.webpackHot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx index 4a96c5167f..9c8144706a 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx @@ -68,20 +68,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -93,7 +100,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -101,6 +116,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -117,41 +137,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx index 421c61943e..ccc0701208 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx @@ -70,20 +70,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -95,7 +102,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -103,6 +118,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -119,41 +139,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx index 12c26d9abf..4cc24510db 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx @@ -73,20 +73,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -98,7 +105,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -106,6 +121,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -122,41 +142,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx index d7dd3606c9..0c965d1cae 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx @@ -67,20 +67,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -92,7 +99,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -100,6 +115,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -116,41 +136,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 1590568616..ff1699d22f 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -79,20 +79,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -104,7 +111,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -112,6 +127,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -128,41 +148,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx index 15dbdf97f7..d973daac13 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx @@ -89,20 +89,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -114,7 +121,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -122,6 +137,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -138,41 +158,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx index 2cd4265d2d..8d38ae7ece 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx @@ -89,20 +89,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -114,7 +121,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -122,6 +137,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -138,41 +158,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index d67e96c10a..c44b6380b4 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -50,20 +50,27 @@ if (import.meta.hot) { oldRoute.update(nextOptions); oldRoute._componentsPromise = undefined; oldRoute._componentPromises = undefined; + oldRoute._componentsLoaded = false; oldRoute._lazyPromise = undefined; + oldRoute._lazyGeneration = (oldRoute._lazyGeneration ?? 0) + 1; + oldRoute._hmrGeneration = (oldRoute._hmrGeneration ?? 0) + 1; + oldRoute._lazyLoaded = false; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); + const activeMatches = router.stores.matches.get(); + const pendingMatches = router.stores.pendingMatches.get(); + const activeMatch = activeMatches.find(filter); + const pendingMatch = pendingMatches.find(filter); const cachedMatches = router.stores.cachedMatches.get().filter(filter); if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); + const projectedAssetsRemoved = removedKeys.has("head") || removedKeys.has("scripts"); + if (removedKeys.has("loader") || removedKeys.has("beforeLoad") || projectedAssetsRemoved) { + const liveStores = [activeMatch && [router.stores.matchStores.get(activeMatch.id), activeMatches], pendingMatch && [router.stores.pendingMatchStores.get(pendingMatch.id), pendingMatches]]; router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); + for (const live of liveStores) { + const store = live?.[0]; if (store) { store.set(prev => { const next = { @@ -75,7 +82,15 @@ if (import.meta.hot) { ; if (removedKeys.has("beforeLoad")) { next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); + next.context = rebuildMatchContextWithoutBeforeLoad(next, live[1]); + } + ; + if (projectedAssetsRemoved) { + next.meta = undefined; + next.links = undefined; + next.headScripts = undefined; + next.scripts = undefined; + next.styles = undefined; } ; return next; @@ -83,6 +98,11 @@ if (import.meta.hot) { } } }); + if (cachedMatches.length > 0) { + router.clearCache({ + filter + }); + } } ; router.invalidate({ @@ -99,41 +119,8 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; + function rebuildMatchContextWithoutBeforeLoad(match, lane) { + const parentContext = lane[match.index - 1]?.context ?? router.options.context; return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}) diff --git a/packages/router-plugin/tests/handle-route-update.test.ts b/packages/router-plugin/tests/handle-route-update.test.ts index 9a80bfbeb4..97b6d0c7c7 100644 --- a/packages/router-plugin/tests/handle-route-update.test.ts +++ b/packages/router-plugin/tests/handle-route-update.test.ts @@ -2,75 +2,83 @@ import { BaseRootRoute, BaseRoute, RouterCore, + createControlledPromise, createNonReactiveMutableStore, createNonReactiveReadonlyStore, trimPathRight, } from '@tanstack/router-core' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '../../history/src' import { getHandleRouteUpdateCode } from '../src/core/hmr' import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } +}) + const getStoreConfig: GetStoreConfig = () => ({ createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, batch: (fn) => fn(), }) -const getHandleRouteUpdate = () => { +const getGeneratedHandleRouteUpdate = () => { return new Function(`return ${getHandleRouteUpdateCode([])}`)() as ( routeId: string, newRoute: AnyRoute, ) => void } -function createTestHistory() { - const location = { - href: '/', - pathname: '/', - search: '', - hash: '', - state: { __TSR_index: 0 }, - } - - return { - location, - length: 1, - subscribers: new Set(), - subscribe: () => () => {}, - push: () => {}, - replace: () => {}, - go: () => {}, - back: () => {}, - forward: () => {}, - canGoBack: () => false, - createHref: (href: string) => href, - block: () => () => {}, - flush: () => {}, - destroy: () => {}, - notify: () => {}, - } -} - function createTestRouter(routeTree: AnyRoute) { return new RouterCore( { routeTree, - history: createTestHistory() as any, + history: createMemoryHistory(), isServer: true, }, getStoreConfig, ) } -function withWindowRouter(router: RouterCore) { +function createClientTestRouter( + routeTree: AnyRoute, + initialEntry: string, + context: Record = {}, +) { + return new RouterCore( + { + routeTree, + history: createMemoryHistory({ initialEntries: [initialEntry] }), + context, + isServer: false, + origin: 'http://localhost', + } as any, + getStoreConfig, + ) +} + +function runHandleRouteUpdate( + router: RouterCore, + routeId: string, + newRoute: AnyRoute, +) { const previousWindow = (globalThis as any).window - ;(globalThis as any).window = { __TSR_ROUTER__: router } + ;(globalThis as any).window = { + ...previousWindow, + __TSR_ROUTER__: router, + } - return () => { - if (previousWindow) { - ;(globalThis as any).window = previousWindow - } else { + try { + getGeneratedHandleRouteUpdate()(routeId, newRoute) + } finally { + if (previousWindow === undefined) { delete (globalThis as any).window + } else { + ;(globalThis as any).window = previousWindow } } } @@ -83,6 +91,98 @@ function getProcessedTreeRouteForPath( } describe('handleRouteUpdate', () => { + it('does not let a pre-HMR component preload hide a failed post-HMR generation', async () => { + const oldPreload = createControlledPromise() + const firstNewPreload = createControlledPromise() + const OldComponent = Object.assign(() => null, { + preload: () => oldPreload, + }) + const newPreload = vi + .fn<() => Promise>() + .mockReturnValueOnce(firstNewPreload) + .mockResolvedValueOnce() + const NewComponent = Object.assign(() => null, { + preload: newPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items', + component: OldComponent, + }) + const router = createTestRouter(rootRoute.addChildren([itemRoute])) + + const oldLoad = router.loadRouteChunk(itemRoute)! + + runHandleRouteUpdate( + router, + itemRoute.id, + new BaseRoute({ component: NewComponent } as any), + ) + + const newLoad = router.loadRouteChunk(itemRoute)! + expect(newPreload).toHaveBeenCalledTimes(1) + + oldPreload.resolve() + await oldLoad + + firstNewPreload.reject(new Error('new chunk failed')) + await expect(newLoad).rejects.toThrow('new chunk failed') + + await router.loadRouteChunk(itemRoute) + expect(newPreload).toHaveBeenCalledTimes(2) + }) + + it('does not let a pre-HMR lazy result overwrite post-HMR route options', async () => { + const oldLazy = createControlledPromise() + const newLazy = createControlledPromise() + const InitialComponent = () => null + const HotComponent = () => null + const StaleLazyComponent = () => null + const currentComponentPreload = vi + .fn<() => Promise>() + .mockRejectedValue(new Error('current component failed')) + const CurrentLazyComponent = Object.assign(() => null, { + preload: currentComponentPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items', + component: InitialComponent, + }) + itemRoute.lazyFn = vi + .fn() + .mockReturnValueOnce(oldLazy) + .mockReturnValueOnce(newLazy) + const router = createTestRouter(rootRoute.addChildren([itemRoute])) + + const oldLoad = router.loadRouteChunk(itemRoute)! + + runHandleRouteUpdate( + router, + itemRoute.id, + new BaseRoute({ component: HotComponent } as any), + ) + + const newLoad = router.loadRouteChunk(itemRoute)! + + newLazy.resolve({ + options: { id: itemRoute.id, component: CurrentLazyComponent }, + }) + await expect(newLoad).rejects.toThrow('current component failed') + expect(itemRoute.options.component).toBe(CurrentLazyComponent) + + oldLazy.resolve({ + options: { id: itemRoute.id, component: StaleLazyComponent }, + }) + await expect(oldLoad).resolves.toBeUndefined() + expect(itemRoute.options.component).toBe(CurrentLazyComponent) + expect(currentComponentPreload).toHaveBeenCalledTimes(1) + }) + it('keeps routesByPath pointed at an index route when a pathless layout updates', () => { const rootRoute = new BaseRootRoute({}) const parentRoute = new BaseRoute({ @@ -112,12 +212,7 @@ describe('handleRouteUpdate', () => { expect(router.routesByPath[key]?.id).toBe(indexRoute.id) - const restoreWindow = withWindowRouter(router) - try { - getHandleRouteUpdate()(pathlessRoute.id, new BaseRoute({} as any)) - } finally { - restoreWindow() - } + runHandleRouteUpdate(router, pathlessRoute.id, new BaseRoute({} as any)) expect(router.routesByPath[key]?.id).toBe(indexRoute.id) expect(router.routesByPath[key]?.id).toBe( @@ -143,12 +238,7 @@ describe('handleRouteUpdate', () => { expect(router.routesByPath[key]?.id).toBe(indexRoute.id) - const restoreWindow = withWindowRouter(router) - try { - getHandleRouteUpdate()(parentRoute.id, new BaseRoute({} as any)) - } finally { - restoreWindow() - } + runHandleRouteUpdate(router, parentRoute.id, new BaseRoute({} as any)) expect(router.routesByPath[key]?.id).toBe(indexRoute.id) expect(router.routesByPath[key]?.id).toBe( @@ -172,25 +262,64 @@ describe('handleRouteUpdate', () => { expect(router.getMatchedRoutes('/items/abc').foundRoute?.id).toBeUndefined() - const restoreWindow = withWindowRouter(router) - try { - getHandleRouteUpdate()( - itemRoute.id, - new BaseRoute({ - params: { - parse: ({ itemId }: { itemId: string }) => ({ itemId }), - }, - } as any), - ) - } finally { - restoreWindow() - } + runHandleRouteUpdate( + router, + itemRoute.id, + new BaseRoute({ + params: { + parse: ({ itemId }: { itemId: string }) => ({ itemId }), + }, + } as any), + ) expect(router.getMatchedRoutes('/items/abc').foundRoute?.id).toBe( itemRoute.id, ) }) + it('refreshes active parsed params when a hot parser keeps the same match id', async () => { + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items/$itemId', + params: { + parse: ({ itemId }: { itemId: string }) => ({ + itemId, + parserVersion: 'old', + }), + }, + }) + const router = createClientTestRouter( + rootRoute.addChildren([itemRoute]), + '/items/abc', + ) + + await router.load() + expect(router.stores.matches.get()[1]!.params).toMatchObject({ + itemId: 'abc', + parserVersion: 'old', + }) + + runHandleRouteUpdate( + router, + itemRoute.id, + new BaseRoute({ + params: { + parse: ({ itemId }: { itemId: string }) => ({ + itemId, + parserVersion: 'new', + }), + }, + } as any), + ) + await router.latestLoadPromise + + expect(router.stores.matches.get()[1]!.params).toMatchObject({ + itemId: 'abc', + parserVersion: 'new', + }) + }) + it('hydrates the hot module route export with generated route tree state', () => { const rootRoute = new BaseRootRoute({}) const itemRoute = new BaseRoute({ @@ -203,12 +332,7 @@ describe('handleRouteUpdate', () => { expect((newRoute as any).to).toBeUndefined() - const restoreWindow = withWindowRouter(router) - try { - getHandleRouteUpdate()(itemRoute.id, newRoute) - } finally { - restoreWindow() - } + runHandleRouteUpdate(router, itemRoute.id, newRoute) expect(newRoute.id).toBe(itemRoute.id) expect(newRoute.path).toBe(itemRoute.path) @@ -217,4 +341,294 @@ describe('handleRouteUpdate', () => { expect((newRoute as any).parentRoute).toBe(rootRoute) expect((newRoute as any).options).toBe((itemRoute as any).options) }) + + it.each(['loader', 'beforeLoad'] as const)( + 'evicts a cached hot-route match when %s is removed', + async (removedOption) => { + const beforeLoad = () => ({ hot: true }) + const loader = () => 'hot data' + const rootRoute = new BaseRootRoute({}) + const hotRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hot', + beforeLoad, + loader, + gcTime: Infinity, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createClientTestRouter( + rootRoute.addChildren([hotRoute, otherRoute]), + '/hot', + ) + + await router.load() + await router.navigate({ to: '/other' }) + + const cachedMatch = router.stores.cachedMatches + .get() + .find((match) => match.routeId === hotRoute.id)! + expect(cachedMatch).toBeDefined() + // Leaving the route already cancels the old owned pass controller. HMR + // only needs to ensure the now-incompatible cache entry is discarded. + expect(cachedMatch.abortController.signal.aborted).toBe(true) + + runHandleRouteUpdate( + router, + hotRoute.id, + new BaseRoute( + (removedOption === 'loader' ? { beforeLoad } : { loader }) as any, + ), + ) + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === hotRoute.id), + ).toBe(false) + }, + ) + + it('rebuilds root context from router and route context when beforeLoad is removed', async () => { + const rootRouteContext = () => ({ rootRouteContext: true }) + const rootRoute = new BaseRootRoute({ + context: rootRouteContext, + beforeLoad: () => ({ rootBeforeLoad: true }), + }) + const router = createClientTestRouter(rootRoute, '/', { + routerContext: true, + }) + + await router.load() + + runHandleRouteUpdate( + router, + rootRoute.id, + new BaseRootRoute({ context: rootRouteContext } as any), + ) + + expect(router.stores.matches.get()[0]!.context).toEqual({ + routerContext: true, + rootRouteContext: true, + }) + }) + + it('rebuilds removed beforeLoad context within each live lane', async () => { + const pendingLoader = createControlledPromise() + let mode = 'active' + const rootRoute = new BaseRootRoute({ + loaderDeps: () => ({ mode }), + context: ({ deps }) => ({ rootMode: deps.mode }), + loader: ({ deps }) => + deps.mode === 'pending' ? pendingLoader : 'active data', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad: () => ({ childBeforeLoad: true }), + }) + const router = createClientTestRouter( + rootRoute.addChildren([childRoute]), + '/child', + ) + + await router.load() + const childId = router.stores.matches.get()[1]!.id + expect(router.stores.matchStores.get(childId)!.get().context).toMatchObject( + { + rootMode: 'active', + childBeforeLoad: true, + }, + ) + + // A changed parent loader-deps key creates a distinct pending parent while + // the passive child keeps the same match id in both pools. + mode = 'pending' + const pendingLoad = router.load() + await vi.waitFor(() => { + expect(router.stores.pendingMatchStores.has(childId)).toBe(true) + expect( + router.stores.pendingMatchStores.get(childId)!.get().context, + ).toMatchObject({ + rootMode: 'pending', + childBeforeLoad: true, + }) + }) + + testCleanups.push(async () => { + pendingLoader.resolve('pending data') + await pendingLoad + }) + runHandleRouteUpdate(router, childRoute.id, new BaseRoute({} as any)) + + // HMR clears the removed child context in both live generations before + // its invalidation commits. Each rebuild must use its own parent lane; + // pending context is not allowed to leak into rendered active state. + expect(router.stores.matchStores.get(childId)!.get().context).toMatchObject( + { + rootMode: 'active', + }, + ) + expect( + router.stores.matchStores.get(childId)!.get().context, + ).not.toHaveProperty('childBeforeLoad') + expect( + router.stores.pendingMatchStores.get(childId)!.get().context, + ).toMatchObject({ + rootMode: 'pending', + }) + expect( + router.stores.pendingMatchStores.get(childId)!.get().context, + ).not.toHaveProperty('childBeforeLoad') + }) + + it('recomputes route context when the context option changes', async () => { + const rootRoute = new BaseRootRoute({ + context: () => ({ source: 'old' }), + }) + const router = createClientTestRouter(rootRoute, '/', { + routerContext: true, + }) + + await router.load() + expect(router.stores.matches.get()[0]!.context).toEqual({ + routerContext: true, + source: 'old', + }) + + runHandleRouteUpdate( + router, + rootRoute.id, + new BaseRootRoute({ + context: () => ({ source: 'new' }), + } as any), + ) + await router.latestLoadPromise + + expect(router.stores.matches.get()[0]!.context).toEqual({ + routerContext: true, + source: 'new', + }) + }) + + it('recomputes descendant route context after a parent context hot update', async () => { + const rootRoute = new BaseRootRoute({ + context: () => ({ source: 'old' }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + context: ({ context }) => ({ + derived: `child-${(context as any).source}`, + }), + }) + const router = createClientTestRouter( + rootRoute.addChildren([childRoute]), + '/child', + ) + + await router.load() + expect(router.stores.matches.get()[1]!.context).toMatchObject({ + source: 'old', + derived: 'child-old', + }) + + runHandleRouteUpdate( + router, + rootRoute.id, + new BaseRootRoute({ + context: () => ({ source: 'new' }), + } as any), + ) + await router.latestLoadPromise + + expect(router.stores.matches.get()[1]!.context).toMatchObject({ + source: 'new', + derived: 'child-new', + }) + }) + + it('clears projected client assets when head and scripts are removed', async () => { + const rootRoute = new BaseRootRoute({}) + const hotRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hot', + head: () => ({ + meta: [{ title: 'hot title' }], + links: [{ rel: 'stylesheet', href: '/hot.css' }], + scripts: [{ src: '/hot-head.js' }], + styles: [{ children: '.hot {}' }], + }), + scripts: () => [{ src: '/hot-body.js' }], + }) + const router = createClientTestRouter( + rootRoute.addChildren([hotRoute]), + '/hot', + ) + + await router.load() + expect(router.stores.matches.get()[1]).toMatchObject({ + meta: [{ title: 'hot title' }], + links: [{ rel: 'stylesheet', href: '/hot.css' }], + headScripts: [{ src: '/hot-head.js' }], + scripts: [{ src: '/hot-body.js' }], + styles: [{ children: '.hot {}' }], + }) + + runHandleRouteUpdate(router, hotRoute.id, new BaseRoute({} as any)) + await router.latestLoadPromise + + const match = router.stores.matches.get()[1]! + expect(match.meta).toBeUndefined() + expect(match.links).toBeUndefined() + expect(match.headScripts).toBeUndefined() + expect(match.scripts).toBeUndefined() + expect(match.styles).toBeUndefined() + }) + + it('clears removed projected assets from active and pending generations with the same id', async () => { + const loaderGate = createControlledPromise() + const rootRoute = new BaseRootRoute({ + head: () => ({ meta: [{ title: 'hot root' }] }), + scripts: () => [{ src: '/hot-root.js' }], + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => loaderGate, + }) + const router = createClientTestRouter( + rootRoute.addChildren([indexRoute, slowRoute]), + '/', + ) + + await router.load() + const navigation = router.navigate({ to: '/slow' }) + await vi.waitFor(() => { + expect(router.stores.pendingMatches.get()).toHaveLength(2) + }) + + const rootId = router.stores.matches.get()[0]!.id + expect(router.stores.pendingMatches.get()[0]!.id).toBe(rootId) + + runHandleRouteUpdate(router, rootRoute.id, new BaseRootRoute({} as any)) + + expect(router.stores.matchStores.get(rootId)!.get().meta).toBeUndefined() + expect(router.stores.matchStores.get(rootId)!.get().scripts).toBeUndefined() + expect( + router.stores.pendingMatchStores.get(rootId)!.get().meta, + ).toBeUndefined() + expect( + router.stores.pendingMatchStores.get(rootId)!.get().scripts, + ).toBeUndefined() + + loaderGate.resolve() + await navigation + }) }) diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 0366d4b71d..d301563467 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -10,15 +10,22 @@ import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' -import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' - -// The scroll restoration script is only ever emitted during SSR. Selecting -// the component through an `isServer === false` constant (same DCE pattern as -// router-core's loadRouter) lets client bundles drop ScrollRestoration and -// its ScriptOnce template entirely; Solid's compiler otherwise hides the -// condition inside a memo where the minifier cannot see it. -const ScrollRestorationScript = - isServer === false ? () => null : ScrollRestoration +import type { + AnyRoute, + AnyRouter, + ParsedLocation, + RootRouteOptions, +} from '@tanstack/router-core' + +// Keep the client constant undefined so Rollup can remove the server-only +// component instead of Solid compiling a reactive false branch around it. +const renderScrollRestoration = + isServer === false + ? undefined + : (router: AnyRouter) => + (isServer ?? router.isServer) && router.options.scrollRestoration ? ( + + ) : null export const Match = (props: { matchId: string }) => { const router = useRouter() @@ -86,8 +93,6 @@ export const Match = (props: { matchId: string }) => { ? resolvedNoSsr : currentMatchState().ssr === 'data-only' - const ResolvedSuspenseBoundary = () => Solid.Suspense - const ResolvedCatchBoundary = () => routeErrorComponent() ? CatchBoundary : SafeFragment @@ -102,8 +107,7 @@ export const Match = (props: { matchId: string }) => { return ( - { - + {currentMatchState().parentRouteId === rootRouteId ? ( <> - {(isServer ?? router.isServer) && - router.options.scrollRestoration ? ( - - ) : null} + {renderScrollRestoration?.(router)} ) : null} @@ -206,13 +207,23 @@ function OnRendered() { ) Solid.createEffect( Solid.on([location], () => { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) + const currentResolvedLocation = router.stores.resolvedLocation.get() + const previousResolvedLocation = (router as any) + .__tsrRendered as ParsedLocation | undefined + if ( + currentResolvedLocation && + (!previousResolvedLocation || + previousResolvedLocation.state.__TSR_key !== location()) + ) { + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo( + currentResolvedLocation, + previousResolvedLocation ?? currentResolvedLocation, + ), + }) + } + ;(router as any).__tsrRendered = currentResolvedLocation }), ) return null @@ -299,31 +310,32 @@ export const MatchInner = (): any => { {(_) => { - const loadPromise = currentMatch()._.loadPromise + const initialLoadPromise = currentMatch()._.loadPromise + // A published pending snapshot can outlive its local owner. + // The router-wide promise waits for this transition to commit, + // so suspending on it here would create a cycle. const promise = - loadPromise?.status === 'pending' - ? loadPromise - : router.latestLoadPromise - - if (process.env.NODE_ENV !== 'production' && !promise) { - throw new Error( - `Invariant failed: pending match "${currentMatch().id}" has no loadPromise`, - ) - } - + initialLoadPromise?.status === 'pending' + ? initialLoadPromise + : undefined const FallbackComponent = PendingComponent() const pendingMinMs = route().options.pendingMinMs ?? router.options.defaultPendingMinMs - if ( - !(isServer ?? router.isServer) && - FallbackComponent && - pendingMinMs && - loadPromise?.status === 'pending' - ) { - loadPromise.pendingUntil ??= Date.now() + pendingMinMs - } - + let pendingUntil: number | undefined + Solid.createRenderEffect(() => { + const loadPromise = currentMatch()._.loadPromise + if ( + !(isServer ?? router.isServer) && + FallbackComponent && + pendingMinMs && + loadPromise?.status === 'pending' + ) { + pendingUntil ??= + loadPromise.pendingUntil ?? Date.now() + pendingMinMs + loadPromise.pendingUntil ??= pendingUntil + } + }) const [loaderResult] = Solid.createResource(() => promise) return ( @@ -396,10 +408,10 @@ export const Outlet = () => { ) const childMatchId = Solid.createMemo(() => { - const currentRouteId = routeId() - return currentRouteId - ? router.stores.childMatchIdByRouteId.get()[currentRouteId] - : undefined + const parentIndex = parentMatch()?.index + return parentIndex === undefined + ? undefined + : router.stores.matchesId.get()[parentIndex + 1] }) const shouldShowNotFound = () => parentGlobalNotFound() diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index 331aff5864..ff14a68f6f 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -14,14 +14,10 @@ export function Transitioner() { const [isSolidTransitioning, startSolidTransition] = Solid.useTransition() - // Track pending state changes - const hasPending = Solid.createMemo(() => router.stores.hasPending.get()) - const isAnyPending = Solid.createMemo( - () => isLoading() || isSolidTransitioning() || hasPending(), + () => isLoading() || isSolidTransitioning(), ) - - const isPagePending = Solid.createMemo(() => isLoading() || hasPending()) + let resolvedChangeInfo: ReturnType | undefined router.startTransition = (fn: () => void | Promise) => { Solid.startTransition(() => { @@ -55,80 +51,77 @@ export function Transitioner() { Solid.onCleanup(() => { unsub() + ;(router as any).__tsrRendered = undefined }) }) // Try to load the initial location Solid.createRenderEffect(() => { Solid.untrack(() => { + const currentLocation = router.stores.location.get() if ( // if we are hydrating from SSR, loading is triggered in ssr-client - (typeof window !== 'undefined' && router.ssr) || + (typeof window !== 'undefined' && + router.ssr && + router.history.location.href === + currentLocation.publicHref && + router.history.location.state.__TSR_key === + currentLocation.state.__TSR_key) || (mountLoadForRouter.router === router && mountLoadForRouter.mounted) ) { return } mountLoadForRouter = { router, mounted: true } - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - tryLoad() + void router.load().catch((err) => console.error(err)) }) }) - Solid.createRenderEffect((previousIsLoading = false) => { + Solid.createComputed((previousIsLoading = false) => { const currentIsLoading = isLoading() if (previousIsLoading && !currentIsLoading) { + const nextResolvedLocation = router.stores.location.get() + resolvedChangeInfo = getLocationChangeInfo( + nextResolvedLocation, + router.stores.resolvedLocation.get(), + ) + // Expose the completed location before lifecycle subscribers run. A + // subscriber may synchronously start the next navigation; status stays + // pending until Solid commits the completed navigation's render. + router.stores.resolvedLocation.set(nextResolvedLocation) router.emit({ type: 'onLoad', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), + ...resolvedChangeInfo, }) - } - - return currentIsLoading - }) - - Solid.createComputed((previousIsPagePending = false) => { - const currentIsPagePending = isPagePending() - - if (previousIsPagePending && !currentIsPagePending) { router.emit({ type: 'onBeforeRouteMount', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), + ...resolvedChangeInfo, }) } - return currentIsPagePending + return currentIsLoading }) Solid.createRenderEffect((previousIsAnyPending = false) => { const currentIsAnyPending = isAnyPending() if (previousIsAnyPending && !currentIsAnyPending) { - const changeInfo = getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ) + const nextResolvedLocation = router.stores.location.get() + const changeInfo = + resolvedChangeInfo ?? + getLocationChangeInfo( + nextResolvedLocation, + router.stores.resolvedLocation.get(), + ) + resolvedChangeInfo = undefined router.emit({ type: 'onResolved', ...changeInfo, }) - Solid.batch(() => { + if (!router.stores.isLoading.get()) { router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) + } } return currentIsAnyPending diff --git a/packages/solid-router/src/routerStores.ts b/packages/solid-router/src/routerStores.ts index 51be801aec..316766e08a 100644 --- a/packages/solid-router/src/routerStores.ts +++ b/packages/solid-router/src/routerStores.ts @@ -5,39 +5,11 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import type { - AnyRoute, GetStoreConfig, RouterReadableStore, - RouterStores, RouterWritableStore, } from '@tanstack/router-core' -declare module '@tanstack/router-core' { - export interface RouterStores { - /** Maps each active routeId to the matchId of its child in the match tree. */ - childMatchIdByRouteId: RouterReadableStore> - } -} - -function initRouterStores( - stores: RouterStores, - createReadonlyStore: ( - read: () => TValue, - ) => RouterReadableStore, -) { - stores.childMatchIdByRouteId = createReadonlyStore(() => { - const ids = stores.matchesId.get() - const obj: Record = {} - for (let i = 0; i < ids.length - 1; i++) { - const parentStore = stores.matchStores.get(ids[i]!) - if (parentStore?.routeId) { - obj[parentStore.routeId] = ids[i + 1]! - } - } - return obj - }) -} - function createSolidMutableStore( initialValue: TValue, ): RouterWritableStore { @@ -70,8 +42,6 @@ export const getStoreFactory: GetStoreConfig = (opts) => { createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, batch: (fn) => fn(), - init: (stores) => - initRouterStores(stores, createNonReactiveReadonlyStore), } } @@ -79,6 +49,5 @@ export const getStoreFactory: GetStoreConfig = (opts) => { createMutableStore: createSolidMutableStore, createReadonlyStore: createSolidReadonlyStore, batch: Solid.batch, - init: (stores) => initRouterStores(stores, createSolidReadonlyStore), } } diff --git a/packages/solid-router/tests/loaders.test.tsx b/packages/solid-router/tests/loaders.test.tsx index f389dcd5f7..241e5729b8 100644 --- a/packages/solid-router/tests/loaders.test.tsx +++ b/packages/solid-router/tests/loaders.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library' import { afterEach, describe, expect, test, vi } from 'vitest' @@ -653,22 +659,23 @@ test('reproducer #4546', async () => { expect(routeContext).toHaveTextContent('3') const loaderData = await screen.findByTestId('index-loader-data') - expect(loaderData).toHaveTextContent('3') + // Re-entering a cached route renders its stale data while the default + // non-blocking reload runs. Wait for that background reload to publish. + await waitFor(() => expect(loaderData).toHaveTextContent('3')) } fireEvent.click(invalidateRouterButton) { - // Wait for router to invalidate and reload - await new Promise((resolve) => setTimeout(resolve, 50)) + // Same-location invalidation is also a non-blocking background reload. + const loaderData = await screen.findByTestId('index-loader-data') + await waitFor(() => expect(loaderData).toHaveTextContent('4')) + const headerCounter = await screen.findByTestId('header-counter') expect(headerCounter).toHaveTextContent('4') const routeContext = await screen.findByTestId('index-route-context') expect(routeContext).toHaveTextContent('4') - - const loaderData = await screen.findByTestId('index-loader-data') - expect(loaderData).toHaveTextContent('4') } fireEvent.click(idLink) @@ -683,7 +690,7 @@ test('reproducer #4546', async () => { expect(routeContext).toHaveTextContent('5') const loaderData = await screen.findByTestId('id-loader-data') - expect(loaderData).toHaveTextContent('5') + await waitFor(() => expect(loaderData).toHaveTextContent('5')) } }) diff --git a/packages/solid-router/tests/on-rendered-change-info.test.tsx b/packages/solid-router/tests/on-rendered-change-info.test.tsx new file mode 100644 index 0000000000..0e98bb9160 --- /dev/null +++ b/packages/solid-router/tests/on-rendered-change-info.test.tsx @@ -0,0 +1,63 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('onRendered describes the navigation from the previously rendered location', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect(event.fromLocation?.pathname).toBe('/') + expect(event.toLocation.pathname).toBe('/next') + expect(event.pathChanged).toBe(true) + expect(event.hrefChanged).toBe(true) + + await router.navigate({ + to: '/next', + state: { sameHrefState: true } as any, + }) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(2)) + + const stateEvent = onRendered.mock.calls[1]![0] + expect((stateEvent.fromLocation?.state as any).sameHrefState).toBeUndefined() + expect((stateEvent.toLocation.state as any).sameHrefState).toBe(true) + expect(stateEvent.fromLocation?.href).toBe('/next') + expect(stateEvent.toLocation.href).toBe('/next') + expect(stateEvent.hrefChanged).toBe(false) + + unsubscribe() +}) diff --git a/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..a6b84fedba --- /dev/null +++ b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,179 @@ +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + vi.useRealTimers() + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a mounted child pending fallback follows a replacement load promise for the same match', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + component: () =>
Generation {pageRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + const firstInvalidation = router.invalidate({ forcePending: true }) + const invalidations = [firstInvalidation] + testCleanups.push(async () => { + firstReload.resolve() + secondReload.resolve() + await Promise.allSettled(invalidations) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + let secondSettled = false + const secondInvalidation = router + .invalidate({ forcePending: true }) + .then(() => { + secondSettled = true + }) + invalidations.push(secondInvalidation) + + firstReload.resolve() + secondReload.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(74) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await Promise.all(invalidations) + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() +}) + +test('the root pending fallback follows a replacement load promise for the same match', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + component: () =>
Generation {rootRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + const firstInvalidation = router.invalidate({ forcePending: true }) + const invalidations = [firstInvalidation] + testCleanups.push(async () => { + firstReload.resolve() + secondReload.resolve() + await Promise.allSettled(invalidations) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + let secondSettled = false + const secondInvalidation = router + .invalidate({ forcePending: true }) + .then(() => { + secondSettled = true + }) + invalidations.push(secondInvalidation) + + firstReload.resolve() + secondReload.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(74) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await Promise.all(invalidations) + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() +}) + +test('forcePending honors pendingMinMs when the reload settles before pendingMs', async () => { + const reload = createControlledPromise() + let loaderCall = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: async () => { + if (++loaderCall > 1) { + await reload + } + return loaderCall + }, + component: () =>
Generation {pageRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + const invalidation = router.invalidate({ forcePending: true }) + expect(await screen.findByTestId('fast-pending')).toBeInTheDocument() + reload.resolve() + + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(screen.getByTestId('fast-pending')).toBeInTheDocument() + + await invalidation + expect(await screen.findByText('Generation 2')).toBeInTheDocument() +}) diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index 8ca5833e5a..7663a9d3a4 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -7,7 +7,11 @@ import { waitFor, } from '@solidjs/testing-library' import { z } from 'zod' -import { composeRewrites, notFound } from '@tanstack/router-core' +import { + composeRewrites, + createControlledPromise, + notFound, +} from '@tanstack/router-core' import { Link, Outlet, @@ -809,6 +813,164 @@ describe('encoding: URL path segment', () => { }) describe('router emits events during rendering', () => { + it.each(['onLoad', 'onBeforeRouteMount'] as const)( + 'keeps completed-navigation lifecycle state stable when %s starts a new navigation', + async (reentrantEvent) => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + await waitFor(() => { + expect(screen.getByText('Index')).toBeInTheDocument() + expect(router.stores.status.get()).toBe('idle') + }) + + const loadPaths: Array = [] + const beforeMountPaths: Array = [] + const resolvedPaths: Array = [] + let nextNavigation: Promise | undefined + const startNextNavigation = (pathname: string) => { + if (pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + } + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + loadPaths.push(event.toLocation.pathname) + if (reentrantEvent === 'onLoad') { + startNextNavigation(event.toLocation.pathname) + } + }), + router.subscribe('onBeforeRouteMount', (event) => { + beforeMountPaths.push(event.toLocation.pathname) + if (reentrantEvent === 'onBeforeRouteMount') { + startNextNavigation(event.toLocation.pathname) + } + }), + router.subscribe('onResolved', (event) => { + resolvedPaths.push(event.toLocation.pathname) + }), + ] + + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + expect(router.state.location.pathname).toBe('/next') + expect(router.stores.isLoading.get()).toBe(true) + expect(router.stores.status.get()).toBe('pending') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/first') + expect(loadPaths).toEqual(['/first']) + expect(beforeMountPaths).toEqual(['/first']) + expect(resolvedPaths).toEqual([]) + }) + + nextLoader.resolve() + await Promise.all([firstNavigation, nextNavigation!]) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + expect(router.stores.isLoading.get()).toBe(false) + expect(router.stores.status.get()).toBe('idle') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/next') + expect(loadPaths).toEqual(['/first', '/next']) + expect(beforeMountPaths).toEqual(['/first', '/next']) + expect(resolvedPaths).toEqual(['/next']) + }) + + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + }, + ) + + it('keeps a reentrant onResolved navigation pending until its loader settles', async () => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + await waitFor(() => { + expect(screen.getByText('Index')).toBeInTheDocument() + expect(router.stores.status.get()).toBe('idle') + }) + + const resolvedPaths: Array = [] + let nextNavigation: Promise | undefined + const unsubscribe = router.subscribe('onResolved', (event) => { + resolvedPaths.push(event.toLocation.pathname) + if (event.toLocation.pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + }) + + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + expect(router.state.location.pathname).toBe('/next') + expect(router.stores.isLoading.get()).toBe(true) + expect(router.stores.status.get()).toBe('pending') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/first') + }) + + nextLoader.resolve() + await Promise.all([firstNavigation, nextNavigation!]) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + expect(router.stores.isLoading.get()).toBe(false) + expect(router.stores.status.get()).toBe('idle') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/next') + }) + expect(resolvedPaths).toEqual(['/first', '/next']) + + unsubscribe() + }) + it('during initial load, should emit the "onResolved" event', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx index c25b933586..786d467162 100644 --- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx @@ -122,6 +122,8 @@ function resolveAfter(ms: number, value: any) { return new Promise((resolve) => setTimeout(() => resolve(value), ms)) } +// A normal navigation deliberately exposes the loaded `resolvedLocation` +// before the later rendered/idle edge, so those are two observable updates. describe("Store doesn't update *too many* times during navigation", () => { test('async loader, async beforeLoad, pendingMs', async () => { const params = setup({ @@ -188,7 +190,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(4) }) test('not found in beforeLoad', async () => { @@ -203,7 +205,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(4) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -229,7 +231,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(4) }) test('navigate, w/ preloaded & async loaders', async () => { @@ -245,7 +247,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(4) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -261,7 +263,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(4) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -277,7 +279,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(4) }) test('preload a preloaded route w/ async loader', async () => { diff --git a/packages/solid-router/tests/transitioner-listener-errors.test.tsx b/packages/solid-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..95d20ac032 --- /dev/null +++ b/packages/solid-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + testCleanups.push(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index route')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const unsubscribe = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeInTheDocument() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + + unsubscribe() + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeInTheDocument() + expect(secondOnEnter).toHaveBeenCalledTimes(1) + await waitFor(() => expect(router.state.status).toBe('idle')) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unhandledRejection).toHaveBeenCalledWith( + listenerError, + expect.anything(), + ) +}) diff --git a/packages/solid-router/tests/transitioner-remount.test.tsx b/packages/solid-router/tests/transitioner-remount.test.tsx new file mode 100644 index 0000000000..cbccf15df2 --- /dev/null +++ b/packages/solid-router/tests/transitioner-remount.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('remounting an SSR-marked router loads a history change that happened while unmounted', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history, + }) + + // This is the stable post-hydration shape: server matches are active and + // the persistent SSR marker tells the first Transitioner mount not to load. + await router.load() + router.ssr = { manifest: { routes: {} } } + + const firstRender = render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + + firstRender.unmount() + history.push('/next') + + const secondRender = render(() => ) + expect(await screen.findByText('Next')).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/next') + + secondRender.unmount() + history.replace('/next', { remounted: true }) + + render(() => ) + await waitFor(() => { + expect((router.state.location.state as any).remounted).toBe(true) + }) +}) + +test('remounting the provider emits onRendered for the newly mounted DOM', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + + const firstRender = render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + firstRender.unmount() + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(2)) + + unsubscribe() +}) diff --git a/packages/solid-router/tests/use-match-outgoing-transition.test.tsx b/packages/solid-router/tests/use-match-outgoing-transition.test.tsx new file mode 100644 index 0000000000..c76c4d78ef --- /dev/null +++ b/packages/solid-router/tests/use-match-outgoing-transition.test.tsx @@ -0,0 +1,108 @@ +import * as Solid from 'solid-js' +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useMatch, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('an outgoing component never observes its own active match disappear', async () => { + const observedRouteIds: Array = [] + const rootRoute = createRootRoute({ component: () => }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () => { + const match = useMatch({ from: '/first', shouldThrow: false }) + Solid.createRenderEffect(() => { + observedRouteIds.push(match()?.routeId) + }) + return
First
+ }, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render(() => ) + expect(await screen.findByText('First')).toBeInTheDocument() + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + + expect(observedRouteIds).toEqual(['/first']) +}) + +test('a persistent observer releases an explicit match only with the destination render', async () => { + const nextLoader = createControlledPromise() + const observations: Array<{ + routeId: string | undefined + destinationRendered: boolean + }> = [] + + const rootRoute = createRootRoute({ + component: () => { + const routeId = useMatch({ + from: '/first', + shouldThrow: false, + select: (match) => match.routeId, + }) + Solid.createRenderEffect(() => { + observations.push({ + routeId: routeId(), + destinationRendered: screen.queryByText('Next') !== null, + }) + }) + return + }, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render(() => ) + expect(await screen.findByText('First')).toBeInTheDocument() + + const navigation = router.navigate({ to: '/next' }) + await Promise.resolve() + expect(screen.queryByText('Next')).toBeNull() + expect(observations.every(({ routeId }) => routeId === '/first')).toBe(true) + + nextLoader.resolve() + await navigation + expect(await screen.findByText('Next')).toBeInTheDocument() + + const missing = observations.filter(({ routeId }) => routeId === undefined) + expect(missing.length).toBeGreaterThan(0) + expect(missing.every(({ destinationRendered }) => destinationRendered)).toBe( + true, + ) +}) diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 796f4f9a8f..1cf8f9c2bd 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -69,7 +69,6 @@ export const Match = Vue.defineComponent({ return { matchId: match.id, - routeId, loadedAt: loadedAt.value, ssr: match.ssr, _displayPending: match._displayPending, @@ -77,7 +76,7 @@ export const Match = Vue.defineComponent({ }) const route = Vue.computed(() => - matchData.value ? router.routesById[matchData.value.routeId] : null, + matchData.value ? router.routesById[routeId] : null, ) const PendingComponent = Vue.computed( @@ -107,14 +106,9 @@ export const Match = Vue.defineComponent({ : route.value?.options?.notFoundComponent, ) - const hasShellComponent = Vue.computed(() => { - if (!route.value?.isRoot) return false - return !!(route.value.options as RootRouteOptions).shellComponent - }) - const ShellComponent = Vue.computed(() => - hasShellComponent.value - ? ((route.value!.options as RootRouteOptions).shellComponent as any) + route.value?.isRoot + ? ((route.value.options as RootRouteOptions).shellComponent as any) : null, ) @@ -138,7 +132,7 @@ export const Match = Vue.defineComponent({ resolvedNoSsr || !!matchData.value?._displayPending const renderMatchContent = (): VNode => { - const matchInner = () => Vue.h(MatchInner, { matchId: actualMatchId }) + const matchInner = () => Vue.h(MatchInner) let content: VNode = shouldClientOnly ? Vue.h( @@ -156,13 +150,13 @@ export const Match = Vue.defineComponent({ if (routeNotFoundComponent.value) { content = Vue.h(CatchNotFound, { fallback: (error: any) => { - error.routeId ??= matchData.value?.routeId + error.routeId ??= routeId // If the current not found handler doesn't exist or it has a // route ID which doesn't match the current route, rethrow the error if ( !routeNotFoundComponent.value || - (error.routeId && error.routeId !== matchData.value?.routeId) || + (error.routeId && error.routeId !== routeId) || (!error.routeId && route.value && !route.value.isRoot) ) throw error @@ -181,7 +175,7 @@ export const Match = Vue.defineComponent({ onCatch: (error: Error) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { - error.routeId ??= matchData.value?.routeId + error.routeId ??= routeId throw error } if (process.env.NODE_ENV !== 'production') { @@ -218,7 +212,7 @@ export const Match = Vue.defineComponent({ return Vue.h(Vue.Fragment, null, withScrollRestoration) } - if (!hasShellComponent.value) { + if (!ShellComponent.value) { return renderMatchContent() } @@ -256,24 +250,26 @@ const OnRendered = Vue.defineComponent({ // remounts this keyed component, which would otherwise reset the tracker. Vue.watch( location, - () => { + (renderedLocationKey) => { const currentResolvedLocation = router.stores.resolvedLocation.get() - const previousResolvedLocation = (router as any) - .__tsrOnRenderedLocation as ParsedLocation | undefined - if ( - currentResolvedLocation && - (!previousResolvedLocation || - previousResolvedLocation.href !== currentResolvedLocation.href) - ) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - previousResolvedLocation ?? currentResolvedLocation, - ), - }) - } - ;(router as any).__tsrOnRenderedLocation = currentResolvedLocation + void Vue.nextTick(() => { + const previousResolvedLocation = (router as any) + .__tsrOnRenderedLocation as ParsedLocation | undefined + if ( + currentResolvedLocation && + (!previousResolvedLocation || + previousResolvedLocation.state.__TSR_key !== renderedLocationKey) + ) { + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo( + currentResolvedLocation, + previousResolvedLocation ?? currentResolvedLocation, + ), + }) + } + ;(router as any).__tsrOnRenderedLocation = currentResolvedLocation + }) }, { immediate: true }, ) @@ -284,13 +280,7 @@ const OnRendered = Vue.defineComponent({ export const MatchInner = Vue.defineComponent({ name: 'MatchInner', - props: { - matchId: { - type: String, - required: true, - }, - }, - setup(props) { + setup() { const router = useRouter() // Use routeId from context (provided by parent Match) — stable string. @@ -367,7 +357,7 @@ export const MatchInner = Vue.defineComponent({ if (match.value!.status === 'notFound') { // status 'notFound' is only ever committed paired with a NotFoundError // (getNotFoundBoundaryPatch), so match.error needs no re-check here. - return renderRouteNotFound(router, route.value!, match.value!.error) + return renderRouteNotFound(router, route.value, match.value!.error) } if (match.value!.status === 'error') { @@ -469,19 +459,21 @@ export const Outlet = Vue.defineComponent({ () => parentMatch.value?.globalNotFound ?? false, ) - // Child match lookup: read the child matchId from the shared derived - // map (one reactive node for the whole tree), then grab match state - // directly from the pool. - const childMatchIdMap = useStore( - router.stores.childMatchIdByRouteId, - (v) => v, - ) + const matchIds = useStore(router.stores.matchesId, (value) => value) const childMatchData = Vue.computed(() => { - const childId = childMatchIdMap.value[parentRouteId] - if (!childId) return null + const parentIndex = parentMatch.value?.index + if (parentIndex === undefined) { + return null + } + const childId = matchIds.value[parentIndex + 1] + if (!childId) { + return null + } const child = router.stores.matchStores.get(childId)?.get() - if (!child) return null + if (!child) { + return null + } return { id: child.id, @@ -494,7 +486,7 @@ export const Outlet = Vue.defineComponent({ return (): VNode | null => { if (parentGlobalNotFound.value) { - return renderRouteNotFound(router, route.value!, undefined) + return renderRouteNotFound(router, route.value, undefined) } if (!childMatchData.value) { diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index da8220db29..72dad5c23a 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -248,16 +248,9 @@ export const MatchRoute = Vue.defineComponent({ }, }, setup(props, { slots }) { - const router = useRouter() - const status = useStore( - router.stores.matchRouteDeps, - (value) => value.status, - ) + const matchRoute = useMatchRoute() return () => { - if (!status.value) return null - - const matchRoute = useMatchRoute() const params = matchRoute(props).value as boolean // Create a component that renders the slot in a reactive manner diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index e042c3b0c1..b02b987262 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -31,16 +31,11 @@ export function useTransitionerSetup() { // the onResolved edge flush-coupled. let transitioning = false - const previous = { - isLoading: false, - isPagePending: false, - isAnyPending: false, - } + const previous: [boolean, boolean] = [false, false] - // Change info captured at the pagePending falling edge, before commitIdle() - // advances resolvedLocation to the new location. Reused for the (possibly - // nextTick-deferred) onResolved emission so it reports the navigation that - // actually resolved instead of a from===to no-op. Reset once consumed. + // Change info captured at the loading falling edge, before resolvedLocation + // advances. Reused for onResolved so it reports the navigation that actually + // resolved instead of a from===to no-op. let resolvedChangeInfo: ReturnType | undefined // Single emitter for the load lifecycle. Level changes arrive through @@ -49,12 +44,11 @@ export function useTransitionerSetup() { // transition edge. One emitter means nothing to arbitrate. const emitEdges = () => { const isLoading = router.stores.isLoading.get() - const isPagePending = isLoading || router.stores.hasPending.get() - const isAnyPending = isPagePending || transitioning - const prev = { ...previous } - previous.isLoading = isLoading - previous.isPagePending = isPagePending - previous.isAnyPending = isAnyPending + const isAnyPending = isLoading || transitioning + const wasLoading = previous[0] + const wasAnyPending = previous[1] + previous[0] = isLoading + previous[1] = isAnyPending const changeInfo = () => getLocationChangeInfo( @@ -62,35 +56,30 @@ export function useTransitionerSetup() { 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() }) - } - // Commit idle/resolvedLocation on the FIRST falling edge (guarded for - // idempotence): these store updates join the same reactive flush as the - // new matches, so matchRoute/status consumers can never render one - // flush behind the committed lane. Only the onResolved EVENT is - // flush-coupled to the deferred transition edge. - const commitIdle = () => { - if (router.stores.status.get() === 'pending') { - batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) - } - } - if (prev.isPagePending && !isPagePending) { resolvedChangeInfo = changeInfo() + // Expose the completed location before lifecycle subscribers run. A + // subscriber may synchronously start the next navigation; status remains + // pending until Vue has flushed the completed navigation's render. + router.stores.resolvedLocation.set(router.stores.location.get()) + router.emit({ type: 'onLoad', ...resolvedChangeInfo }) router.emit({ type: 'onBeforeRouteMount', ...resolvedChangeInfo }) - commitIdle() } - if (prev.isAnyPending && !isAnyPending) { + // Idle state and onResolved are flush-coupled to the transition edge so + // public idle never precedes the destination DOM. + if (wasAnyPending && !isAnyPending) { router.emit({ type: 'onResolved', ...(resolvedChangeInfo ?? changeInfo()), }) resolvedChangeInfo = undefined - commitIdle() + if ( + !router.stores.isLoading.get() && + router.stores.status.get() === 'pending' + ) { + router.stores.status.set('idle') + } } } @@ -116,14 +105,10 @@ export function useTransitionerSetup() { // Armed before the mount load below, so every load — including one that // settled before mount (the mount load below still toggles isLoading) — // produces an observable edge. - previous.isLoading = router.stores.isLoading.get() - previous.isPagePending = previous.isLoading || router.stores.hasPending.get() - previous.isAnyPending = previous.isPagePending || transitioning + previous[0] = router.stores.isLoading.get() + previous[1] = previous[0] || transitioning - const subscriptions = [ - router.stores.isLoading.subscribe(emitEdges), - router.stores.hasPending.subscribe(emitEdges), - ] + const isLoadingSubscription = router.stores.isLoading.subscribe(emitEdges) // Vue updates DOM asynchronously (next tick). The View Transitions API expects the // update callback promise to resolve only after the DOM has been updated. @@ -138,7 +123,7 @@ export function useTransitionerSetup() { originalStartViewTransition router.startViewTransition = (fn: () => Promise) => { - return originalStartViewTransition?.(async () => { + return originalStartViewTransition(async () => { await fn() await Vue.nextTick() }) @@ -178,7 +163,6 @@ export function useTransitionerSetup() { Vue.onMounted(() => { if ( !router.stores.isLoading.get() && - !router.stores.hasPending.get() && !transitioning && router.stores.status.get() === 'pending' ) { @@ -190,9 +174,8 @@ export function useTransitionerSetup() { }) Vue.onUnmounted(() => { - for (const subscription of subscriptions) { - subscription.unsubscribe() - } + ;(router as any).__tsrOnRenderedLocation = undefined + isLoadingSubscription.unsubscribe() if (unsubscribe) { unsubscribe() } @@ -200,21 +183,20 @@ export function useTransitionerSetup() { // Try to load the initial location Vue.onMounted(() => { + const currentLocation = router.stores.location.get() + const locationIsCurrent = + router.history.location.href === currentLocation.publicHref && + router.history.location.state.__TSR_key === + currentLocation.state.__TSR_key if ( - (typeof window !== 'undefined' && router.ssr) || - (mountLoadForRouter.router === router && mountLoadForRouter.mounted) + locationIsCurrent && + ((typeof window !== 'undefined' && router.ssr) || + (mountLoadForRouter.router === router && mountLoadForRouter.mounted)) ) { return } mountLoadForRouter = { router, mounted: true } - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - tryLoad() + void router.load().catch((err) => console.error(err)) }) } diff --git a/packages/vue-router/src/routerStores.ts b/packages/vue-router/src/routerStores.ts index dec689c47c..a7aafa0287 100644 --- a/packages/vue-router/src/routerStores.ts +++ b/packages/vue-router/src/routerStores.ts @@ -1,17 +1,9 @@ import { batch, createAtom } from '@tanstack/vue-store' -import type { - AnyRoute, - GetStoreConfig, - RouterStores, -} from '@tanstack/router-core' +import type { GetStoreConfig } from '@tanstack/router-core' import type { Readable } from '@tanstack/vue-store' declare module '@tanstack/router-core' { export interface RouterReadableStore extends Readable {} - export interface RouterStores { - /** Maps each active routeId to the matchId of its child in the match tree. */ - childMatchIdByRouteId: RouterReadableStore> - } } export const getStoreFactory: GetStoreConfig = (_opts) => { @@ -19,22 +11,5 @@ export const getStoreFactory: GetStoreConfig = (_opts) => { createMutableStore: createAtom, createReadonlyStore: createAtom, batch, - init: (stores: RouterStores) => { - // Single derived store: one reactive node that maps every active - // routeId to its child's matchId. Depends only on matchesId + - // the pool's routeId tags (which are set during reconciliation). - // Outlet reads the map and then does a direct pool lookup. - stores.childMatchIdByRouteId = createAtom(() => { - const ids = stores.matchesId.get() - const obj: Record = {} - for (let i = 0; i < ids.length - 1; i++) { - const parentStore = stores.matchStores.get(ids[i]!) - if (parentStore?.routeId) { - obj[parentStore.routeId] = ids[i + 1]! - } - } - return obj - }) - }, } } diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 969d7ce788..c25635f023 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -111,6 +111,7 @@ export function useMatch< } // Set up reactive match value based on lookup strategy. + const nearestRouteId = Vue.inject(routeIdContext) let match: Readonly> if (opts.from) { @@ -122,7 +123,6 @@ export function useMatch< // matchId case: use routeId from context for stable store lookup. // The routeId is provided by the nearest Match component and doesn't // change for the component's lifetime, so the store is stable. - const nearestRouteId = Vue.inject(routeIdContext) if (nearestRouteId) { match = useStore( router.stores.getRouteMatchStore(nearestRouteId), @@ -134,9 +134,28 @@ export function useMatch< } } + const status = + opts.from && opts.from !== nearestRouteId + ? useStore(router.stores.status, (value) => value) + : undefined + + let previous: any + let hadMatch = false const result = Vue.computed(() => { const selectedMatch = match.value if (selectedMatch === undefined) { + // An injected nearest match or explicit self-match can disappear while + // its component waits for Vue to unmount it. Cross-route observers only + // retain while the replacement navigation is pending. + if ( + hadMatch && + (!opts.from || + opts.from === nearestRouteId || + status!.value === 'pending') + ) { + return previous + } + hadMatch = false if (opts.shouldThrow ?? true) { if (process.env.NODE_ENV !== 'production') { throw new Error( @@ -150,7 +169,9 @@ export function useMatch< return undefined } - return opts.select ? opts.select(selectedMatch) : selectedMatch + hadMatch = true + previous = opts.select ? opts.select(selectedMatch) : selectedMatch + return previous }) // Keep eager throw behavior for setups that call useMatch for side effects only. diff --git a/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx new file mode 100644 index 0000000000..04130792c2 --- /dev/null +++ b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,181 @@ +import * as Vue from 'vue' +import { renderToString } from 'vue/server-renderer' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { hydrate as hydrateRouter } from '@tanstack/router-core/ssr/client' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + notFound, +} from '../src' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.restoreAllMocks() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +describe('hydrating a server-capped boundary lane', () => { + test.each([ + ['error', 'parent'], + ['notFound', 'root'], + ] as const)( + 'keeps the server-rendered %s %s boundary visible while the client replays it', + async (outcome, boundary) => { + const childLoader = vi.fn(() => 'child data') + const makeRouteTree = ( + head?: () => Record | Promise>, + ) => { + const boundaryOptions = { + beforeLoad: () => { + throw outcome === 'notFound' + ? notFound() + : new Error('server route failure') + }, + head, + pendingComponent: () => ( +
Boundary pending
+ ), + errorComponent: () => ( +
Boundary error
+ ), + notFoundComponent: () => ( +
Boundary not found
+ ), + } + const rootRoute = createRootRoute({ + component: Outlet, + ...(boundary === 'root' ? boundaryOptions : {}), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + ...(boundary === 'parent' ? boundaryOptions : {}), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: () =>
Child
, + }) + return rootRoute.addChildren([parentRoute.addChildren([childRoute])]) + } + + const serverRouter = createRouter({ + routeTree: makeRouteTree(), + ...(boundary === 'root' ? { isShell: true } : {}), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + serverRouter.isServer = true + await serverRouter.load() + + const serverMatches = serverRouter.stores.matches.get() + expect(serverMatches).toHaveLength(boundary === 'root' ? 1 : 2) + expect(serverMatches.at(-1)).toMatchObject( + boundary === 'root' + ? { status: 'success', globalNotFound: true } + : { status: 'error' }, + ) + + const serverApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + const serverHtml = await renderToString(serverApp) + const expectedBoundary = + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error' + expect(serverHtml).toContain(expectedBoundary) + + // Keep route-asset projection from the follow-up replay pending so the + // first hydrated frame cannot race the replay's final commit. + const replayAssets = createControlledPromise>() + const clientHead = vi + .fn<() => Record | Promise>>() + .mockReturnValueOnce({}) + .mockReturnValueOnce(replayAssets) + const clientRouter = createRouter({ + routeTree: makeRouteTree(clientHead), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: serverMatches.at(-1)!.id, + matches: serverMatches.map((match) => ({ + i: match.id, + u: match.updatedAt, + s: match.status, + l: match.loaderData, + e: match.error, + ssr: match.ssr, + ...(match.globalNotFound ? { g: true } : {}), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrateRouter(clientRouter) + await vi.waitFor(() => { + expect(clientHead).toHaveBeenCalledTimes(2) + expect(clientRouter.stores.isLoading.get()).toBe(true) + }) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const clientApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + testCleanups.push(async () => { + clientApp.unmount() + replayAssets.resolve({}) + await clientRouter.latestLoadPromise + }) + + clientApp.mount(container) + await Vue.nextTick() + + expect(container).toHaveTextContent(expectedBoundary) + expect(container).not.toHaveTextContent('Boundary pending') + expect( + [consoleError.mock.calls, consoleWarn.mock.calls].flat(2).join(' '), + ).not.toMatch(/hydration|mismatch/i) + expect(childLoader).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..8cab6d41f0 --- /dev/null +++ b/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + vi.useRealTimers() + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a continuously visible fallback keeps its deadline across replacement loads', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + component: () =>
Content
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Content')).toBeInTheDocument() + + vi.useFakeTimers() + const firstInvalidation = router.invalidate({ forcePending: true }) + const invalidations = [firstInvalidation] + testCleanups.push(async () => { + firstReload.resolve() + secondReload.resolve() + await Promise.allSettled(invalidations) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + let secondSettled = false + const secondInvalidation = router + .invalidate({ forcePending: true }) + .then(() => { + secondSettled = true + }) + invalidations.push(secondInvalidation) + + firstReload.resolve() + secondReload.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(74) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await Promise.all(invalidations) + expect(screen.getByText('Content')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() +}) diff --git a/packages/vue-router/tests/router.test.tsx b/packages/vue-router/tests/router.test.tsx index edc6e206cc..44a2d7be75 100644 --- a/packages/vue-router/tests/router.test.tsx +++ b/packages/vue-router/tests/router.test.tsx @@ -7,7 +7,11 @@ import { waitFor, } from '@testing-library/vue' import { z } from 'zod' -import { composeRewrites, notFound } from '@tanstack/router-core' +import { + composeRewrites, + createControlledPromise, + notFound, +} from '@tanstack/router-core' import { Link, Outlet, @@ -811,6 +815,101 @@ describe('encoding: URL path segment', () => { }) describe('router emits events during rendering', () => { + it.each(['onLoad', 'onBeforeRouteMount'] as const)( + 'keeps completed-navigation lifecycle state stable when %s starts a new navigation', + async (reentrantEvent) => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + await waitFor(() => { + expect(screen.getByText('Index')).toBeInTheDocument() + expect(router.stores.status.get()).toBe('idle') + }) + + const loadPaths: Array = [] + const beforeMountPaths: Array = [] + const resolvedPaths: Array = [] + let nextNavigation: Promise | undefined + const startNextNavigation = (pathname: string) => { + if (pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + } + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + loadPaths.push(event.toLocation.pathname) + if (reentrantEvent === 'onLoad') { + startNextNavigation(event.toLocation.pathname) + } + }), + router.subscribe('onBeforeRouteMount', (event) => { + beforeMountPaths.push(event.toLocation.pathname) + if (reentrantEvent === 'onBeforeRouteMount') { + startNextNavigation(event.toLocation.pathname) + } + }), + router.subscribe('onResolved', (event) => { + resolvedPaths.push(event.toLocation.pathname) + }), + ] + + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + expect(router.state.location.pathname).toBe('/next') + expect(router.stores.isLoading.get()).toBe(true) + expect(router.stores.status.get()).toBe('pending') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/first') + expect(loadPaths).toEqual(['/first']) + expect(beforeMountPaths).toEqual(['/first']) + // The subscriber superseded /first before Vue flushed its render, so it + // has not fully resolved and must not emit onResolved. + expect(resolvedPaths).toEqual([]) + }) + + nextLoader.resolve() + await Promise.all([firstNavigation, nextNavigation!]) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + expect(router.stores.isLoading.get()).toBe(false) + expect(router.stores.status.get()).toBe('idle') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/next') + expect(loadPaths).toEqual(['/first', '/next']) + expect(beforeMountPaths).toEqual(['/first', '/next']) + expect(resolvedPaths).toEqual(['/next']) + }) + + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + }, + ) + it('during initial load, should emit the "onResolved" event', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), @@ -863,6 +962,73 @@ describe('router emits events during rendering', () => { unsub() }) + it('keeps a reentrant onResolved navigation pending until its loader settles', async () => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + await waitFor(() => { + expect(screen.getByText('Index')).toBeInTheDocument() + expect(router.stores.status.get()).toBe('idle') + }) + + const resolvedPaths: Array = [] + let nextNavigation: Promise | undefined + const unsubscribe = router.subscribe('onResolved', (event) => { + resolvedPaths.push(event.toLocation.pathname) + if (event.toLocation.pathname === '/first') { + // A public lifecycle subscriber may synchronously start the next + // navigation before the previous emitEdges() call returns. + nextNavigation = router.navigate({ to: '/next' }) + } + }) + + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + expect(router.state.location.pathname).toBe('/next') + expect(router.stores.isLoading.get()).toBe(true) + expect(router.stores.status.get()).toBe('pending') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/first') + }) + + nextLoader.resolve() + await Promise.all([firstNavigation, nextNavigation!]) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + expect(router.stores.isLoading.get()).toBe(false) + expect(router.stores.status.get()).toBe('idle') + expect(router.stores.resolvedLocation.get()?.pathname).toBe('/next') + }) + expect(resolvedPaths).toEqual(['/first', '/next']) + + unsubscribe() + }) + it('should emit "onRendered" with the previous location as fromLocation', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx index 388e76c5fa..188a64ae38 100644 --- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx @@ -139,8 +139,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // Any change that increases this number should be investigated. // (Reduced by one across the board when the transitioner began committing // idle/resolvedLocation in the same flush as the matches commit.) - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(3) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(4) }) test('redirection in preload', async () => { @@ -181,8 +181,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(3) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(4) }) test('nothing', async () => { @@ -193,8 +193,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(2) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(3) }) test('not found in beforeLoad', async () => { @@ -209,8 +209,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(2) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(3) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -236,8 +236,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(2) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(3) }) test('navigate, w/ preloaded & async loaders', async () => { @@ -253,8 +253,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(2) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(3) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -270,8 +270,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(2) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(3) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -287,8 +287,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(2) + // Vue observes loaded-location and render-coupled idle as distinct updates. + expect(updates).toBe(3) }) test('preload a preloaded route w/ async loader', async () => { diff --git a/packages/vue-router/tests/transitioner-idle-after-render.test.tsx b/packages/vue-router/tests/transitioner-idle-after-render.test.tsx new file mode 100644 index 0000000000..fb19f3bda1 --- /dev/null +++ b/packages/vue-router/tests/transitioner-idle-after-render.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('status becomes idle only after Vue commits the destination DOM', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const destinationWasRenderedWhenIdle: Array = [] + const subscription = router.stores.status.subscribe(() => { + if (router.stores.status.get() === 'idle') { + destinationWasRenderedWhenIdle.push( + screen.queryByText('Next') !== null, + ) + } + }) + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + + expect(destinationWasRenderedWhenIdle).toEqual([true]) + subscription.unsubscribe() +}) diff --git a/packages/vue-router/tests/transitioner-listener-errors.test.tsx b/packages/vue-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..d4259ad785 --- /dev/null +++ b/packages/vue-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + testCleanups.push(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index route')).toBeTruthy() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const unsubscribe = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeTruthy() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + + unsubscribe() + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeTruthy() + expect(secondOnEnter).toHaveBeenCalledTimes(1) + await waitFor(() => expect(router.state.status).toBe('idle')) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unhandledRejection).toHaveBeenCalledWith( + listenerError, + expect.anything(), + ) +}) diff --git a/packages/vue-router/tests/transitioner-remount-rendered.test.tsx b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx new file mode 100644 index 0000000000..fa96101aa4 --- /dev/null +++ b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx @@ -0,0 +1,129 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +function setup() { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history, + }) + return { history, router } +} + +test('remounting the same router loads a history change that happened while unmounted', async () => { + const { history, router } = setup() + const firstRender = render() + expect(await screen.findByText('Index')).toBeTruthy() + + firstRender.unmount() + history.push('/next') + + render() + expect(await screen.findByText('Next')).toBeTruthy() + expect(router.state.location.pathname).toBe('/next') +}) + +test('remounting the provider emits onRendered for the newly mounted DOM', async () => { + const { router } = setup() + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + + const firstRender = render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + firstRender.unmount() + render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(2)) + + unsubscribe() +}) + +test('remounting an SSR-marked router loads a history change that happened while unmounted', async () => { + const { history, router } = setup() + + // This is the stable post-hydration shape: server matches are active and + // the persistent SSR marker tells the first Transitioner mount not to load. + await router.load() + router.ssr = { manifest: { routes: {} } } + + const firstRender = render() + expect(await screen.findByText('Index')).toBeTruthy() + + firstRender.unmount() + history.push('/next') + + render() + expect(await screen.findByText('Next')).toBeTruthy() + expect(router.state.location.pathname).toBe('/next') +}) + +test('onRendered runs after the destination DOM has committed', async () => { + const { router } = setup() + const initialRendered = vi.fn() + const unsubscribeInitial = router.subscribe('onRendered', initialRendered) + render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(initialRendered).toHaveBeenCalledTimes(1)) + unsubscribeInitial() + + const destinationWasRendered: Array = [] + const unsubscribe = router.subscribe('onRendered', () => { + destinationWasRendered.push(screen.queryByText('Next') !== null) + }) + + await router.navigate({ to: '/next' }) + await waitFor(() => expect(destinationWasRendered).toHaveLength(1)) + expect(destinationWasRendered).toEqual([true]) + + unsubscribe() +}) + +test('onRendered fires for a same-href navigation with a new history key', async () => { + const { router } = setup() + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + onRendered.mockClear() + + await router.navigate({ + to: '/', + state: { sameHrefState: true } as any, + }) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect((event.fromLocation?.state as any).sameHrefState).toBeUndefined() + expect((event.toLocation.state as any).sameHrefState).toBe(true) + expect(event.fromLocation?.href).toBe('/') + expect(event.toLocation.href).toBe('/') + expect(event.hrefChanged).toBe(false) + + unsubscribe() +}) diff --git a/packages/vue-router/tests/use-match-outgoing-transition.test.tsx b/packages/vue-router/tests/use-match-outgoing-transition.test.tsx new file mode 100644 index 0000000000..21d9aa3816 --- /dev/null +++ b/packages/vue-router/tests/use-match-outgoing-transition.test.tsx @@ -0,0 +1,185 @@ +import * as Vue from 'vue' +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useMatch, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('an outgoing component never observes its own active match disappear', async () => { + const observedRouteIds: Array = [] + const rootRoute = createRootRoute({ component: () => }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () => { + const match = useMatch({ from: '/first', shouldThrow: false }) + // Sync watchers are commonly used to coordinate non-render state. They + // run as soon as the granular route store changes, before Vue's parent + // render has had a chance to unmount this outgoing component. + Vue.watchEffect( + () => { + observedRouteIds.push(match.value?.routeId) + }, + { flush: 'sync' }, + ) + return
First
+ }, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render() + expect(await screen.findByText('First')).toBeInTheDocument() + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + + expect(observedRouteIds).toEqual(['/first']) +}) + +test('a persistent observer releases an explicit match only after the destination renders', async () => { + const nextLoader = createControlledPromise() + const observedRouteIds: Array = [] + const destinationRenderedWhenMissing: Array = [] + + const rootRoute = createRootRoute({ + component: () => { + const routeId = useMatch({ + from: '/first', + shouldThrow: false, + select: (match) => match.routeId, + }) + Vue.watchEffect( + () => { + const value = routeId.value + observedRouteIds.push(value) + if (value === undefined) { + destinationRenderedWhenMissing.push( + screen.queryByText('Next') !== null, + ) + } + }, + { flush: 'sync' }, + ) + return + }, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render() + expect(await screen.findByText('First')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + expect(observedRouteIds).toEqual(['/first']) + + const navigation = router.navigate({ to: '/next' }) + await waitFor(() => { + expect(router.state.isLoading).toBe(true) + expect(router.state.status).toBe('pending') + }) + + // The persistent observer must not see the old route disappear while the + // destination has not committed yet. + expect(screen.queryByText('Next')).toBeNull() + expect(observedRouteIds).toEqual(['/first']) + + nextLoader.resolve() + await navigation + expect(await screen.findByText('Next')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + await waitFor(() => expect(observedRouteIds).toContain(undefined)) + + expect(observedRouteIds[0]).toBe('/first') + expect(observedRouteIds.slice(1).every((value) => value === undefined)).toBe( + true, + ) + expect(destinationRenderedWhenMissing.length).toBeGreaterThan(0) + expect(destinationRenderedWhenMissing.every(Boolean)).toBe(true) +}) + +test('a child does not observe itself disappear before a background parent error unmounts it', async () => { + let rejectReload!: (error: Error) => void + let loaderCalls = 0 + const parentLoader = vi.fn(() => { + loaderCalls++ + if (loaderCalls === 1) { + return 'initial' + } + return new Promise((_resolve, reject) => { + rejectReload = reject + }) + }) + const observedRouteIds: Array = [] + const rootRoute = createRootRoute({ component: () => }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: 0, + component: () => , + errorComponent: () =>
Parent error
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + component: () => { + const match = useMatch({ + strict: false, + shouldThrow: false, + }) + Vue.watchEffect( + () => { + observedRouteIds.push(match.value?.routeId) + }, + { flush: 'sync' }, + ) + return
Child
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + render() + expect(await screen.findByText('Child')).toBeInTheDocument() + + await new Promise((resolve) => setTimeout(resolve, 1)) + await router.load() + await waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + rejectReload(new Error('background failed')) + + expect(await screen.findByText('Parent error')).toBeInTheDocument() + expect(observedRouteIds).toEqual(['/parent/child']) +})