diff --git a/.changeset/fine-doors-stop.md b/.changeset/fine-doors-stop.md
new file mode 100644
index 0000000000..d47902fc7a
--- /dev/null
+++ b/.changeset/fine-doors-stop.md
@@ -0,0 +1,8 @@
+---
+'@tanstack/react-router': patch
+'@tanstack/solid-router': patch
+'@tanstack/router-core': patch
+'@tanstack/vue-router': patch
+---
+
+preserve context during reloads
diff --git a/e2e/react-router/issue-4759/src/main.tsx b/e2e/react-router/issue-4759/src/main.tsx
index bc4a99c37f..e251178045 100644
--- a/e2e/react-router/issue-4759/src/main.tsx
+++ b/e2e/react-router/issue-4759/src/main.tsx
@@ -1,13 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import {
+ Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router'
-const rootRoute = createRootRoute()
+const rootRoute = createRootRoute({
+ context: () => ({ locale: 'en' }),
+ component: RootComponent,
+})
+function RootComponent() {
+ const { locale } = rootRoute.useRouteContext()
+ return (
+
+
+
+ )
+}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
@@ -19,18 +31,34 @@ const routePendingRoute = createRoute({
path: '/route-pending',
pendingMs: 0,
pendingMinMs: 0,
- pendingComponent: () => (
-
- route pending
-
- ),
+ pendingComponent: RoutePending,
loader: () => new Promise((resolve) => setTimeout(resolve, 1_000)),
component: () => loaded
,
})
+function RoutePending() {
+ const { locale } = routePendingRoute.useRouteContext()
+ return (
+
+ route pending
+
+ )
+}
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, routePendingRoute]),
})
const usesRoutePending = window.location.pathname === '/route-pending'
+function DefaultPending() {
+ const { locale } = indexRoute.useRouteContext()
+ return (
+
+ default pending
+
+ )
+}
createRoot(document.getElementById('app')!).render(
@@ -39,11 +67,7 @@ createRoot(document.getElementById('app')!).render(
router={router}
defaultPendingMs={usesRoutePending ? 1_000 : 0}
defaultPendingMinMs={0}
- defaultPendingComponent={() => (
-
- default pending
-
- )}
+ defaultPendingComponent={DefaultPending}
/>
,
diff --git a/e2e/react-router/issue-4759/tests/issue-4759.spec.ts b/e2e/react-router/issue-4759/tests/issue-4759.spec.ts
index 7556b1c47a..95689010c8 100644
--- a/e2e/react-router/issue-4759/tests/issue-4759.spec.ts
+++ b/e2e/react-router/issue-4759/tests/issue-4759.spec.ts
@@ -5,11 +5,12 @@ async function expectNoBlankFrame(
page: Page,
pendingSource: 'default' | 'route',
) {
- await expect(
- page.locator(
- `[data-state="pending"][data-pending-source="${pendingSource}"]`,
- ),
- ).toBeVisible()
+ const pending = page.locator(
+ `[data-state="pending"][data-pending-source="${pendingSource}"]`,
+ )
+ await expect(pending).toBeVisible()
+ await expect(pending).toHaveAttribute('data-locale', 'en')
+ await expect(page.locator('[data-root-locale="en"]')).toBeVisible()
const paintStates = await page.evaluate(
() =>
diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx
index 244b2d021f..ddf8afea2e 100644
--- a/packages/react-router/src/Transitioner.tsx
+++ b/packages/react-router/src/Transitioner.tsx
@@ -87,7 +87,7 @@ export function Transitioner({
}
})
} else if (!router._tx) {
- router.load().catch(console.error)
+ router.load({ sync: true }).catch(console.error)
}
return unsub
diff --git a/packages/react-router/tests/issue-8115-context.test.tsx b/packages/react-router/tests/issue-8115-context.test.tsx
new file mode 100644
index 0000000000..69399d26c0
--- /dev/null
+++ b/packages/react-router/tests/issue-8115-context.test.tsx
@@ -0,0 +1,785 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { hydrateRoot } from 'react-dom/client'
+import { afterEach, expect, test, vi } from 'vitest'
+import {
+ Outlet,
+ RouterProvider,
+ Scripts,
+ createControlledPromise,
+ createMemoryHistory,
+ createRootRoute,
+ createRootRouteWithContext,
+ createRoute,
+ createRouter,
+ useLocation,
+} from '../src'
+import { hydrate } from '../src/ssr/client'
+import {
+ RouterServer,
+ createRequestHandler,
+ renderRouterToString,
+} from '../src/ssr/server'
+
+declare module '@tanstack/history' {
+ interface HistoryState {
+ issue8115Revision?: 'old' | 'new'
+ }
+}
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ delete window.$_TSR
+ delete (window as Window & { $R?: unknown }).$R
+ document.body.innerHTML = ''
+})
+
+test('invalidate merges fresh parent beforeLoad context with cached child context', async () => {
+ let generation = 0
+ let childContextCalls = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => {
+ generation++
+ return {
+ parentGeneration: generation,
+ collision: `parent-${generation}`,
+ }
+ },
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ context }) => {
+ childContextCalls++
+ return {
+ childSnapshotOfParent: context.parentGeneration,
+ collision: `child-snapshot-${context.parentGeneration}`,
+ }
+ },
+ component: () => {
+ const { parentGeneration, childSnapshotOfParent, collision } =
+ childRoute.useRouteContext()
+
+ return (
+
+
{parentGeneration}
+
{childSnapshotOfParent}
+
{collision}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByTestId('parent-generation')).toHaveTextContent('1')
+ expect(screen.getByTestId('child-snapshot')).toHaveTextContent('1')
+ expect(screen.getByTestId('collision')).toHaveTextContent('child-snapshot-1')
+ expect(generation).toBe(1)
+ expect(childContextCalls).toBe(1)
+ const childMatchId = router.state.matches[1]!.id
+
+ await act(() => router.invalidate())
+
+ // The fresh parent contribution is merged under the cached child contribution.
+ expect(router.state.matches[1]!.id).toBe(childMatchId)
+ expect(generation).toBe(2)
+ expect(childContextCalls).toBe(1)
+ expect(screen.getByTestId('parent-generation')).toHaveTextContent('2')
+ expect(screen.getByTestId('child-snapshot')).toHaveTextContent('1')
+ expect(screen.getByTestId('collision')).toHaveTextContent('child-snapshot-1')
+})
+
+test('a same-id child beforeLoad error observes fresh inherited context', async () => {
+ const childError = new Error('child beforeLoad failed')
+ let parentGeneration = 0
+ let renderedError: unknown
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loaderDeps: () => ({ stable: true }),
+ beforeLoad: () => ({ generation: ++parentGeneration }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({ stable: true }),
+ beforeLoad: ({ context }) => {
+ if (context.generation === 2) {
+ throw childError
+ }
+ },
+ component: () => (
+
+ {childRoute.useRouteContext().generation}
+
+ ),
+ errorComponent: ({ error }) => {
+ renderedError = error
+ const context = childRoute.useRouteContext()
+
+ return (
+ {context.generation}
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
+ })
+
+ render()
+
+ expect(await screen.findByTestId('child-generation')).toHaveTextContent('1')
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ const initialChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+ expect(initialChildMatchId).toBeDefined()
+
+ await act(() => router.invalidate())
+
+ expect(
+ await screen.findByTestId('child-error-generation'),
+ ).toBeInTheDocument()
+ expect(renderedError).toBe(childError)
+ expect(
+ router.state.matches.find((match) => match.routeId === childRoute.id)?.id,
+ ).toBe(initialChildMatchId)
+ expect(screen.getByTestId('child-error-generation')).toHaveTextContent('2')
+})
+
+test('a same-match reload merges new provider context with cached route context', async () => {
+ type ProviderContext = {
+ providerValue: string
+ collision: string
+ }
+
+ const providerA: ProviderContext = {
+ providerValue: 'A',
+ collision: 'provider:A',
+ }
+ const providerB: ProviderContext = {
+ providerValue: 'B',
+ collision: 'provider:B',
+ }
+ const rootRoute = createRootRouteWithContext()()
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ context }) => ({
+ derivedFromProvider: `derived:${context.providerValue}`,
+ collision: `route-cached:${context.providerValue}`,
+ }),
+ component: () => (
+
+ {JSON.stringify(indexRoute.useRouteContext())}
+
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ context: providerA,
+ })
+
+ const view = render()
+ expect(await screen.findByTestId('full-context')).toHaveTextContent(
+ JSON.stringify({
+ providerValue: 'A',
+ collision: 'route-cached:A',
+ derivedFromProvider: 'derived:A',
+ }),
+ )
+
+ view.rerender()
+ await act(() => router.invalidate())
+
+ expect(screen.getByTestId('full-context')).toHaveTextContent(
+ JSON.stringify({
+ providerValue: 'B',
+ collision: 'route-cached:A',
+ derivedFromProvider: 'derived:A',
+ }),
+ )
+})
+
+test('navigation merges fresh parent context with cached child preload context', async () => {
+ let parentBeforeLoadRuns = 0
+ let childContextRuns = 0
+ let childLoaderRuns = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: ({ preload }) => {
+ parentBeforeLoadRuns++
+ return {
+ parentValue: preload ? 'parent-preload' : 'parent-navigation',
+ }
+ },
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ context: ({ context, preload }) => {
+ childContextRuns++
+ return {
+ childValue: `${preload ? 'child-preload' : 'child-navigation'}:${context.parentValue}`,
+ }
+ },
+ loader: () => {
+ childLoaderRuns++
+ return 'child data'
+ },
+ preloadStaleTime: Infinity,
+ component: () => {
+ const { parentValue, childValue } = childRoute.useRouteContext()
+ return (
+
+ {JSON.stringify({ parentValue, childValue })}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+
+ await act(() => router.preloadRoute({ to: '/parent/child' }))
+ expect(parentBeforeLoadRuns).toBe(1)
+ expect(childContextRuns).toBe(1)
+ expect(childLoaderRuns).toBe(1)
+
+ await act(() => router.navigate({ to: '/parent/child' }))
+
+ expect(await screen.findByTestId('context')).toHaveTextContent(
+ JSON.stringify({
+ parentValue: 'parent-navigation',
+ childValue: 'child-preload:parent-preload',
+ }),
+ )
+ expect(parentBeforeLoadRuns).toBe(2)
+ expect(childContextRuns).toBe(1)
+ expect(childLoaderRuns).toBe(1)
+})
+
+test('a cached child context contribution is merged with fresh parent context', async () => {
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ version: Number(search.version),
+ }),
+ loaderDeps: ({ search }) => ({ version: search.version }),
+ context: ({ deps }) => ({ parentVersion: `version-${deps.version}` }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({ stable: true }),
+ context: ({ context }) => ({
+ childSnapshot: `derived-from-${context.parentVersion}`,
+ }),
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ Parent: {context.parentVersion}; cached child: {context.childSnapshot}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?version=1'],
+ }),
+ })
+
+ render()
+
+ expect(
+ await screen.findByText(
+ 'Parent: version-1; cached child: derived-from-version-1',
+ ),
+ ).toBeInTheDocument()
+
+ const initialParentMatchId = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )?.id
+ const initialChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+
+ expect(initialParentMatchId).toBeDefined()
+ expect(initialChildMatchId).toBeDefined()
+
+ await act(() =>
+ router.navigate({
+ to: '/parent/child',
+ search: { version: 2 },
+ }),
+ )
+
+ expect(
+ screen.getByText('Parent: version-2; cached child: derived-from-version-1'),
+ ).toBeInTheDocument()
+
+ const nextParentMatchId = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )?.id
+ const nextChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+
+ expect(nextParentMatchId).not.toBe(initialParentMatchId)
+ expect(nextChildMatchId).toBe(initialChildMatchId)
+})
+
+test('#8115: hydration does not render a successful route with missing context when context reconstruction fails', async () => {
+ const contextError = new Error('client context reconstruction failed')
+ const clientSuccessRenderValues: Array = []
+ let clientContextAttempts = 0
+ let serverPhase = true
+
+ const createRouteTree = () => {
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: (): { locale: string } => {
+ if (serverPhase) {
+ return { locale: 'en' }
+ }
+ clientContextAttempts++
+ throw contextError
+ },
+ component: () => {
+ const context: { locale?: string } = indexRoute.useRouteContext()
+ if (!serverPhase) {
+ clientSuccessRenderValues.push(context.locale)
+ }
+ return (
+
+ Locale: {context.locale ?? 'missing'}
+
+ )
+ },
+ errorComponent: ({ error }) => (
+
+ {error instanceof Error ? error.message : String(error)}
+
+ ),
+ })
+
+ return rootRoute.addChildren([indexRoute])
+ }
+
+ const response = await createRequestHandler({
+ request: new Request('http://localhost/'),
+ createRouter: () =>
+ createRouter({ routeTree: createRouteTree(), isServer: true }),
+ })(({ router, responseHeaders }) =>
+ renderRouterToString({
+ router,
+ responseHeaders,
+ children: (
+
+
+
+
+
+
+ ),
+ }),
+ )
+ const html = await response.text()
+ const serverDocument = new DOMParser().parseFromString(html, 'text/html')
+
+ expect(serverDocument.body.textContent).toContain('Locale: en')
+ const currentScriptSpy = vi.spyOn(document, 'currentScript', 'get')
+ try {
+ // These scripts come exclusively from this test's renderRouterToString output.
+ for (const script of serverDocument.querySelectorAll('script')) {
+ currentScriptSpy.mockReturnValue(script)
+ new Function(script.textContent ?? '')()
+ script.remove()
+ }
+ } finally {
+ currentScriptSpy.mockRestore()
+ }
+ expect(window.$_TSR?.router?.matches.at(-1)?.s).toBe('success')
+
+ serverPhase = false
+ const clientRouter = createRouter({
+ routeTree: createRouteTree(),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ const container = document.createElement('div')
+ container.innerHTML = serverDocument.body.innerHTML
+ document.body.appendChild(container)
+ const recoverableHydrationErrors: Array = []
+ let root: ReturnType | undefined
+
+ try {
+ await hydrate(clientRouter)
+ await act(async () => {
+ root = hydrateRoot(container, , {
+ onRecoverableError: (error) => {
+ const messages = [
+ error instanceof Error ? error.message : String(error),
+ error instanceof Error && error.cause instanceof Error
+ ? error.cause.message
+ : '',
+ ]
+ if (
+ error instanceof Error &&
+ messages.some((message) =>
+ /hydration (?:failed|mismatch)|server rendered HTML.*client/i.test(
+ message,
+ ),
+ )
+ ) {
+ recoverableHydrationErrors.push(error)
+ return
+ }
+ throw error
+ },
+ })
+ await Promise.resolve()
+ })
+
+ await waitFor(() => {
+ expect(
+ container.querySelector('[data-testid="route-error"]'),
+ ).toHaveTextContent(contextError.message)
+ })
+ expect(clientContextAttempts).toBeGreaterThan(0)
+ expect(container.querySelector('[data-testid="route-success"]')).toBeNull()
+ expect(clientSuccessRenderValues).not.toContain(undefined)
+ expect(recoverableHydrationErrors.length).toBeGreaterThan(0)
+ } finally {
+ if (root) {
+ await act(() => root!.unmount())
+ }
+ container.remove()
+ }
+})
+
+test('a same-id child retry presents one coherent beforeLoad context generation', async () => {
+ const childReloadStarted = createControlledPromise()
+ const childReload = createControlledPromise()
+ let parentGeneration = 0
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: () => ({ generation: ++parentGeneration }),
+ component: () => (
+
+
+ Parent generation {parentRoute.useRouteContext().generation}
+
+
+
+ ),
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ beforeLoad: ({ context }) => ({
+ inheritedGeneration: context.generation,
+ }),
+ loader: async () => {
+ if (++childLoads > 1) {
+ childReloadStarted.resolve()
+ await childReload
+ }
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => (
+
+ Child generation {childRoute.useRouteContext().inheritedGeneration}
+
+ ),
+ component: () => (
+
+ Child generation {childRoute.useRouteContext().inheritedGeneration}
+
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('parent-generation')).toHaveTextContent(
+ 'Parent generation 1',
+ )
+ expect(screen.getByTestId('child-generation')).toHaveTextContent(
+ 'Child generation 1',
+ )
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ const initialChildId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+ expect(initialChildId).toBeDefined()
+
+ let invalidation: Promise | undefined
+ try {
+ await act(async () => {
+ invalidation = router.invalidate({
+ filter: (match) =>
+ match.routeId === parentRoute.id || match.routeId === childRoute.id,
+ forcePending: true,
+ })
+ await childReloadStarted
+ })
+
+ expect(screen.getByTestId('parent-generation')).toHaveTextContent(
+ 'Parent generation 2',
+ )
+ expect(screen.getByTestId('child-pending-generation')).toHaveTextContent(
+ 'Child generation 2',
+ )
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'success',
+ context: { generation: 2 },
+ })
+ expect(
+ router.state.matches.find((match) => match.routeId === childRoute.id),
+ ).toMatchObject({
+ id: initialChildId,
+ status: 'pending',
+ context: { generation: 2, inheritedGeneration: 2 },
+ })
+ } finally {
+ childReload.resolve()
+ await act(async () => {
+ await Promise.allSettled(invalidation ? [invalidation] : [])
+ })
+ }
+})
+
+test('a same-id navigation merges new inherited context with cached route context', async () => {
+ const history = createMemoryHistory({ initialEntries: ['/'] })
+ history.replace('/', { issue8115Revision: 'old' })
+
+ const rootRoute = createRootRoute({
+ beforeLoad: ({ location }) => ({
+ inheritedRevision: location.state.issue8115Revision,
+ }),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ location }) => ({
+ selfRevision: location.state.issue8115Revision,
+ }),
+ component: IndexComponent,
+ })
+ function IndexComponent() {
+ const location = useLocation()
+ const context = indexRoute.useRouteContext()
+ const matchId = indexRoute.useMatch({ select: (match) => match.id })
+ const navigate = indexRoute.useNavigate()
+
+ return (
+ <>
+
+
+
+ >
+ )
+ }
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history,
+ })
+
+ render()
+
+ expect(await screen.findByTestId('snapshot')).toHaveTextContent(
+ 'location: old; inherited: old; self: old',
+ )
+ const initialMatchId = screen.getByTestId('match-id').textContent
+
+ fireEvent.click(screen.getByRole('button', { name: 'Update state' }))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('match-id').textContent).toBe(initialMatchId)
+ expect(screen.getByTestId('snapshot')).toHaveTextContent(
+ 'location: new; inherited: new; self: old',
+ )
+ })
+})
+
+test('#8115: a successful root never renders without its context while a child is pending on cold load', async () => {
+ let resolveChildLoader!: () => void
+ const childLoader = new Promise((resolve) => {
+ resolveChildLoader = resolve
+ })
+ const rootRenderValues: Array = []
+
+ const rootRoute = createRootRoute({
+ context: () => ({ locale: 'en' }),
+ component: () => {
+ const locale = rootRoute.useRouteContext().locale
+ rootRenderValues.push(locale)
+
+ return (
+
+ Locale: {locale ?? 'missing'}
+
+
+ )
+ },
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ loader: () => childLoader,
+ pendingMs: 0,
+ pendingComponent: () => Loading child
,
+ component: () => Child content
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ try {
+ expect(await screen.findByRole('status')).toHaveTextContent('Loading child')
+ expect(screen.getByTestId('root-locale')).toHaveTextContent('Locale: en')
+ expect(rootRenderValues.length).toBeGreaterThan(0)
+ expect(rootRenderValues.every((locale) => locale === 'en')).toBe(true)
+ } finally {
+ await act(async () => {
+ resolveChildLoader()
+ await childLoader
+ })
+ }
+})
+
+test('a same-id search navigation merges fresh inherited context with cached route context', async () => {
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ revision: typeof search.revision === 'string' ? search.revision : 'one',
+ }),
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ context: ({ deps }) => ({ inheritedRevision: deps.revision }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({}),
+ context: ({ context, location }) => ({
+ cachedSelfRevision: `${context.inheritedRevision}:${String((location.search as any).revision)}`,
+ }),
+ component: () => {
+ const context = childRoute.useRouteContext()
+ const search = childRoute.useSearch()
+ const matchId = childRoute.useMatch({ select: (match) => match.id })
+
+ return (
+ <>
+ {matchId}
+ {search.revision}
+ {context.inheritedRevision}
+
+ {context.cachedSelfRevision}
+
+ >
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?revision=one'],
+ }),
+ })
+
+ render()
+
+ expect(await screen.findByTestId('current-search')).toHaveTextContent('one')
+ expect(screen.getByTestId('inherited-context')).toHaveTextContent('one')
+ expect(screen.getByTestId('cached-self-context')).toHaveTextContent('one:one')
+ const initialMatchId = screen.getByTestId('match-id').textContent
+
+ await act(() =>
+ router.navigate({
+ to: '/parent/child',
+ search: { revision: 'two' },
+ }),
+ )
+
+ expect(await screen.findByTestId('current-search')).toHaveTextContent('two')
+ expect(screen.getByTestId('match-id').textContent).toBe(initialMatchId)
+ expect(screen.getByTestId('inherited-context')).toHaveTextContent('two')
+ expect(screen.getByTestId('cached-self-context')).toHaveTextContent('one:one')
+})
diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts
index 034284bec6..2d89949001 100644
--- a/packages/router-core/src/load-client.ts
+++ b/packages/router-core/src/load-client.ts
@@ -13,7 +13,6 @@ import type { AnyRouteMatch } from './Matches'
import type { NotFoundError } from './not-found'
import type {
AnyRoute,
- BeforeLoadContextOptions,
LoaderFnContext,
RouteContextOptions,
RouteLoaderFn,
@@ -380,22 +379,19 @@ async function contextualize(
matches,
routeId: route.id,
}
- let context = parentContext
try {
- let routeContext = match._ctx
- if (!routeContext && route.options.context) {
- routeContext = match._ctx =
- route.options.context({
+ // Reuse the route's cached contribution while rebuilding its inheritance.
+ const routeContext = (match._ctx ||= route.options.context
+ ? route.options.context({
...common,
deps: match.loaderDeps,
context: parentContext,
} satisfies RouteContextOptions) || {}
- }
- context = {
+ : undefined)
+ match.context = {
...parentContext,
...routeContext,
}
- match.context = context
} catch (cause) {
releaseFlight(router, match)
return [index, normalizeLaneError(router, lane, route, cause, options)]
@@ -416,23 +412,6 @@ async function contextualize(
continue
}
- const beforeLoadContext: BeforeLoadContextOptions<
- any,
- any,
- any,
- any,
- any,
- any,
- any,
- any,
- any
- > = {
- ...common,
- search: match.search,
- context,
- ...router.options.additionalContext,
- }
-
const previousStatus = match.status
if (index >= retainedEnd) {
match.status = 'pending'
@@ -440,7 +419,15 @@ async function contextualize(
}
try {
setFetching(router, match, 'beforeLoad', options[0 /* controller */])
- const result = await waitFor(beforeLoad(beforeLoadContext), signal)
+ const result = await waitFor(
+ beforeLoad({
+ ...common,
+ search: match.search,
+ context: match.context,
+ ...router.options.additionalContext,
+ }),
+ signal,
+ )
if (signal.aborted) {
return [index, CANCELED_OUTCOME]
}
@@ -456,16 +443,14 @@ async function contextualize(
return [index, outcome]
}
match.context = {
- ...context,
+ ...match.context,
...result,
}
} catch (cause) {
releaseFlight(router, match)
return [index, normalizeLaneError(router, lane, route, cause, options)]
} finally {
- if (match.status === 'pending') {
- match.status = previousStatus
- }
+ match.status = previousStatus
setFetching(router, match, false, options[0 /* controller */])
}
}
@@ -1988,26 +1973,26 @@ export async function loadClientRoute(
}
router._preflight = undefined
+ let settle: ((value: void | PromiseLike) => void) | undefined
+ const run = () =>
+ runClientTransaction(
+ router,
+ tx,
+ sameHref,
+ () => offerPending(router, tx),
+ opts?.sync,
+ resolvedPrefix,
+ )
+ const done = opts?.sync
+ ? new Promise((resolve) => (settle = resolve))
+ : Promise.resolve().then(run).then()
const tx: LoadTransaction = [
controller,
redirects,
location,
matches,
Date.now(),
- Promise.resolve()
- .then(() =>
- runClientTransaction(
- router,
- tx,
- sameHref,
- () => offerPending(router, tx),
- opts?.sync,
- resolvedPrefix,
- ),
- )
- // Preserve the settlement turn in which immediately completed background
- // work can publish before callers resume from `load`.
- .then(),
+ done,
]
if (process.env.NODE_ENV !== 'production' && rematerialize) {
tx[6 /* refresh */] = [handoff]
@@ -2034,6 +2019,7 @@ export async function loadClientRoute(
if (router._tx !== tx) {
transferMatchResources(router, tx[3 /* matches */])
tx[3 /* matches */] = []
+ settle?.()
await awaitCurrent(router, tx)
return
}
@@ -2041,15 +2027,19 @@ export async function loadClientRoute(
router.stores.status.set('pending')
router.stores.location.set(location)
})
- // Cold loads have no committed UI to retain, but provisional not-found
- // matches must wait for lazy routes to place the final boundary.
+ // An unresolved cold root has no UI to retain. Provisional not-found waits
+ // for lazy routes to place the final boundary.
if (
resolvedPrefix ||
- (!router._committed.length && !matches.some((match) => match._notFound))
+ (!router._committed.length &&
+ matches[0]?.status !== 'success' &&
+ !matches.some((match) => match._notFound))
) {
offerPending(router, tx)
}
- await tx[5 /* done */]
+ // Let explicit synchronous loads publish ready pending work before paint.
+ settle?.(run())
+ await done
await awaitCurrent(router, tx)
}
@@ -2441,6 +2431,8 @@ export async function hydrate(router: AnyRouter): Promise {
match.status !== 'notFound' &&
!match._notFound
) {
+ // Never present transported success without reconstructed context.
+ pendingBoundary = Math.min(pendingBoundary ?? index, index)
retryFrom(index)
break
}
diff --git a/packages/router-core/tests/public-hydration-contract.test.ts b/packages/router-core/tests/public-hydration-contract.test.ts
index 836931ee91..c593bdf020 100644
--- a/packages/router-core/tests/public-hydration-contract.test.ts
+++ b/packages/router-core/tests/public-hydration-contract.test.ts
@@ -1321,7 +1321,8 @@ describe('public hydration contracts', () => {
expect(router.state.matches.at(-1)).toMatchObject({
routeId: pageRoute.id,
- status: 'success',
+ status: 'pending',
+ ssr: false,
error: undefined,
loaderData: 'server data',
})
@@ -1376,7 +1377,8 @@ describe('public hydration contracts', () => {
expect(router.state.location.pathname).toBe('/source')
expect(router.state.matches.at(-1)).toMatchObject({
routeId: sourceRoute.id,
- status: 'success',
+ status: 'pending',
+ ssr: false,
})
expect(router.state.resolvedLocation).toBeUndefined()
diff --git a/packages/solid-router/tests/issue-8115-context.test.tsx b/packages/solid-router/tests/issue-8115-context.test.tsx
new file mode 100644
index 0000000000..659cb59309
--- /dev/null
+++ b/packages/solid-router/tests/issue-8115-context.test.tsx
@@ -0,0 +1,768 @@
+import { Show, createMemo, createSignal } from 'solid-js'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@solidjs/testing-library'
+import { afterEach, expect, test, vi } from 'vitest'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server'
+import {
+ Outlet,
+ RouterProvider,
+ Scripts,
+ createControlledPromise,
+ createMemoryHistory,
+ createRootRoute,
+ createRootRouteWithContext,
+ createRoute,
+ createRouter,
+ useLocation,
+} from '../src'
+
+declare module '@tanstack/history' {
+ interface HistoryState {
+ issue8115Revision?: 'old' | 'new'
+ }
+}
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ delete window.$_TSR
+ delete (window as Window & { $R?: unknown }).$R
+ document.body.innerHTML = ''
+})
+
+test('invalidate merges fresh parent beforeLoad context with cached child context', async () => {
+ let generation = 0
+ let childContextCalls = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => {
+ generation++
+ return {
+ parentGeneration: generation,
+ collision: `parent-${generation}`,
+ }
+ },
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ context }) => {
+ childContextCalls++
+ return {
+ childSnapshotOfParent: context.parentGeneration,
+ collision: `child-snapshot-${context.parentGeneration}`,
+ }
+ },
+ component: () => {
+ const context = childRoute.useRouteContext()
+
+ return (
+
+
+ {context().parentGeneration}
+
+
+ {context().childSnapshotOfParent}
+
+
{context().collision}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+
+ expect(await screen.findByTestId('parent-generation')).toHaveTextContent('1')
+ expect(screen.getByTestId('child-snapshot')).toHaveTextContent('1')
+ expect(screen.getByTestId('collision')).toHaveTextContent('child-snapshot-1')
+ expect(generation).toBe(1)
+ expect(childContextCalls).toBe(1)
+ const childMatchId = router.state.matches[1]!.id
+
+ await router.invalidate()
+
+ // The fresh parent contribution is merged under the cached child contribution.
+ expect(router.state.matches[1]!.id).toBe(childMatchId)
+ expect(generation).toBe(2)
+ expect(childContextCalls).toBe(1)
+ await waitFor(() => {
+ expect(screen.getByTestId('parent-generation')).toHaveTextContent('2')
+ expect(screen.getByTestId('child-snapshot')).toHaveTextContent('1')
+ expect(screen.getByTestId('collision')).toHaveTextContent(
+ 'child-snapshot-1',
+ )
+ })
+})
+
+test('a same-id child beforeLoad error observes fresh inherited context', async () => {
+ const childError = new Error('child beforeLoad failed')
+ let parentGeneration = 0
+ let renderedError: unknown
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loaderDeps: () => ({ stable: true }),
+ beforeLoad: () => ({ generation: ++parentGeneration }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({ stable: true }),
+ beforeLoad: ({ context }) => {
+ if (context.generation === 2) {
+ throw childError
+ }
+ },
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return {context().generation}
+ },
+ errorComponent: ({ error }) => {
+ renderedError = error
+ const context = childRoute.useRouteContext()
+
+ return (
+ {context().generation}
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
+ })
+
+ render(() => )
+
+ expect(await screen.findByTestId('child-generation')).toHaveTextContent('1')
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ const initialChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+ expect(initialChildMatchId).toBeDefined()
+
+ await router.invalidate()
+
+ expect(
+ await screen.findByTestId('child-error-generation'),
+ ).toBeInTheDocument()
+ expect(renderedError).toBe(childError)
+ expect(
+ router.state.matches.find((match) => match.routeId === childRoute.id)?.id,
+ ).toBe(initialChildMatchId)
+ expect(screen.getByTestId('child-error-generation')).toHaveTextContent('2')
+})
+
+test('a same-match reload merges new provider context with cached route context', async () => {
+ type ProviderContext = {
+ providerValue: string
+ collision: string
+ }
+
+ const providerA: ProviderContext = {
+ providerValue: 'A',
+ collision: 'provider:A',
+ }
+ const providerB: ProviderContext = {
+ providerValue: 'B',
+ collision: 'provider:B',
+ }
+ const rootRoute = createRootRouteWithContext()()
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ context }) => ({
+ derivedFromProvider: `derived:${context.providerValue}`,
+ collision: `route-cached:${context.providerValue}`,
+ }),
+ component: () => {
+ const context = indexRoute.useRouteContext()
+ return {JSON.stringify(context())}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ context: providerA,
+ })
+ const [provider, setProvider] = createSignal(providerA)
+
+ render(() => (
+
+ {(context) => }
+
+ ))
+ expect(await screen.findByTestId('full-context')).toHaveTextContent(
+ JSON.stringify({
+ providerValue: 'A',
+ collision: 'route-cached:A',
+ derivedFromProvider: 'derived:A',
+ }),
+ )
+
+ setProvider(providerB)
+ await Promise.resolve()
+ await router.invalidate()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('full-context')).toHaveTextContent(
+ JSON.stringify({
+ providerValue: 'B',
+ collision: 'route-cached:A',
+ derivedFromProvider: 'derived:A',
+ }),
+ )
+ })
+})
+
+test('navigation merges fresh parent context with cached child preload context', async () => {
+ let parentBeforeLoadRuns = 0
+ let childContextRuns = 0
+ let childLoaderRuns = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: ({ preload }) => {
+ parentBeforeLoadRuns++
+ return {
+ parentValue: preload ? 'parent-preload' : 'parent-navigation',
+ }
+ },
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ context: ({ context, preload }) => {
+ childContextRuns++
+ return {
+ childValue: `${preload ? 'child-preload' : 'child-navigation'}:${context.parentValue}`,
+ }
+ },
+ loader: () => {
+ childLoaderRuns++
+ return 'child data'
+ },
+ preloadStaleTime: Infinity,
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ {JSON.stringify({
+ parentValue: context().parentValue,
+ childValue: context().childValue,
+ })}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+
+ await router.preloadRoute({ to: '/parent/child' })
+ expect(parentBeforeLoadRuns).toBe(1)
+ expect(childContextRuns).toBe(1)
+ expect(childLoaderRuns).toBe(1)
+
+ await router.navigate({ to: '/parent/child' })
+
+ expect(await screen.findByTestId('context')).toHaveTextContent(
+ JSON.stringify({
+ parentValue: 'parent-navigation',
+ childValue: 'child-preload:parent-preload',
+ }),
+ )
+ expect(parentBeforeLoadRuns).toBe(2)
+ expect(childContextRuns).toBe(1)
+ expect(childLoaderRuns).toBe(1)
+})
+
+test('a cached child context contribution is merged with fresh parent context', async () => {
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ version: Number(search.version),
+ }),
+ loaderDeps: ({ search }) => ({ version: search.version }),
+ context: ({ deps }) => ({ parentVersion: `version-${deps.version}` }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({ stable: true }),
+ context: ({ context }) => ({
+ childSnapshot: `derived-from-${context.parentVersion}`,
+ }),
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ Parent: {context().parentVersion}; cached child:{' '}
+ {context().childSnapshot}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?version=1'],
+ }),
+ })
+
+ render(() => )
+
+ expect(
+ await screen.findByText(
+ 'Parent: version-1; cached child: derived-from-version-1',
+ ),
+ ).toBeInTheDocument()
+
+ const initialParentMatchId = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )?.id
+ const initialChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+
+ expect(initialParentMatchId).toBeDefined()
+ expect(initialChildMatchId).toBeDefined()
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { version: 2 },
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'Parent: version-2; cached child: derived-from-version-1',
+ ),
+ ).toBeInTheDocument()
+ })
+
+ const nextParentMatchId = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )?.id
+ const nextChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+
+ expect(nextParentMatchId).not.toBe(initialParentMatchId)
+ expect(nextChildMatchId).toBe(initialChildMatchId)
+})
+
+test('#8115: hydration does not render a successful route with missing context when context reconstruction fails', async () => {
+ const contextError = new Error('client context reconstruction failed')
+ const clientSuccessRenderValues: Array = []
+ let clientContextAttempts = 0
+ let serverPhase = true
+
+ const createRouteTree = () => {
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: (): { locale: string } => {
+ if (serverPhase) {
+ return { locale: 'en' }
+ }
+ clientContextAttempts++
+ throw contextError
+ },
+ component: () => {
+ const context = indexRoute.useRouteContext()
+ const locale = createMemo(() => {
+ const value: string | undefined = context().locale
+ if (!serverPhase) {
+ clientSuccessRenderValues.push(value)
+ }
+ return value
+ })
+ return (
+ Locale: {locale() ?? 'missing'}
+ )
+ },
+ errorComponent: ({ error }) => (
+
+ {error instanceof Error ? error.message : String(error)}
+
+ ),
+ })
+
+ return rootRoute.addChildren([indexRoute])
+ }
+
+ const serverRouter = createRouter({
+ routeTree: createRouteTree(),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ isServer: true,
+ })
+ attachRouterServerSsrUtils({ router: serverRouter, manifest: undefined })
+ const currentScriptSpy = vi.spyOn(document, 'currentScript', 'get')
+ try {
+ await serverRouter.load()
+ expect(serverRouter.state.matches.at(-1)?.context).toMatchObject({
+ locale: 'en',
+ })
+ expect(serverRouter.state.matches.at(-1)?.status).toBe('success')
+
+ await serverRouter.serverSsr!.dehydrate()
+ const script = serverRouter.serverSsr!.takeBufferedScripts()
+ expect(script?.children).toBeTruthy()
+ currentScriptSpy.mockReturnValue(document.createElement('script'))
+ // This script comes exclusively from the router's production SSR serializer.
+ new Function(script!.children!)()
+ } finally {
+ currentScriptSpy.mockRestore()
+ serverRouter.serverSsr?.cleanup()
+ }
+ expect(window.$_TSR?.router?.matches.at(-1)?.s).toBe('success')
+
+ serverPhase = false
+ const clientRouter = createRouter({
+ routeTree: createRouteTree(),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await hydrate(clientRouter)
+ render(() => )
+
+ expect(await screen.findByTestId('route-error')).toHaveTextContent(
+ contextError.message,
+ )
+ expect(clientContextAttempts).toBeGreaterThan(0)
+ expect(screen.queryByTestId('route-success')).not.toBeInTheDocument()
+ expect(clientSuccessRenderValues).not.toContain(undefined)
+})
+
+test('a same-id child retry presents one coherent beforeLoad context generation', async () => {
+ const childReloadStarted = createControlledPromise()
+ const childReload = createControlledPromise()
+ let parentGeneration = 0
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: () => ({ generation: ++parentGeneration }),
+ component: () => {
+ const context = parentRoute.useRouteContext()
+ return (
+
+
+ Parent generation {context().generation}
+
+
+
+ )
+ },
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ beforeLoad: ({ context }) => ({
+ inheritedGeneration: context.generation,
+ }),
+ loader: async () => {
+ if (++childLoads > 1) {
+ childReloadStarted.resolve()
+ await childReload
+ }
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ Child generation {context().inheritedGeneration}
+
+ )
+ },
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ Child generation {context().inheritedGeneration}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByTestId('parent-generation')).toHaveTextContent(
+ 'Parent generation 1',
+ )
+ expect(await screen.findByTestId('child-generation')).toHaveTextContent(
+ 'Child generation 1',
+ )
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ const initialChildId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+ expect(initialChildId).toBeDefined()
+
+ let invalidation: Promise | undefined
+ try {
+ invalidation = router.invalidate({
+ filter: (match) =>
+ match.routeId === parentRoute.id || match.routeId === childRoute.id,
+ forcePending: true,
+ })
+ await childReloadStarted
+
+ await waitFor(() => {
+ expect(screen.getByTestId('parent-generation')).toHaveTextContent(
+ 'Parent generation 2',
+ )
+ expect(screen.getByTestId('child-pending-generation')).toHaveTextContent(
+ 'Child generation 2',
+ )
+ })
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'success',
+ context: { generation: 2 },
+ })
+ expect(
+ router.state.matches.find((match) => match.routeId === childRoute.id),
+ ).toMatchObject({
+ id: initialChildId,
+ status: 'pending',
+ context: { generation: 2, inheritedGeneration: 2 },
+ })
+ } finally {
+ childReload.resolve()
+ await Promise.allSettled(invalidation ? [invalidation] : [])
+ }
+})
+
+test('a same-id navigation merges new inherited context with cached route context', async () => {
+ const history = createMemoryHistory({ initialEntries: ['/'] })
+ history.replace('/', { issue8115Revision: 'old' })
+
+ const rootRoute = createRootRoute({
+ beforeLoad: ({ location }) => ({
+ inheritedRevision: location.state.issue8115Revision,
+ }),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ location }) => ({
+ selfRevision: location.state.issue8115Revision,
+ }),
+ component: () => {
+ const location = useLocation()
+ const context = indexRoute.useRouteContext()
+ const matchId = indexRoute.useMatch({ select: (match) => match.id })
+ const navigate = indexRoute.useNavigate()
+
+ return (
+ <>
+
+
+
+ >
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history,
+ })
+
+ render(() => )
+
+ expect(await screen.findByTestId('snapshot')).toHaveTextContent(
+ 'location: old; inherited: old; self: old',
+ )
+ const initialMatchId = screen.getByTestId('match-id').textContent
+
+ fireEvent.click(screen.getByRole('button', { name: 'Update state' }))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('match-id').textContent).toBe(initialMatchId)
+ expect(screen.getByTestId('snapshot')).toHaveTextContent(
+ 'location: new; inherited: new; self: old',
+ )
+ })
+})
+
+test('#8115: a successful root never renders without its context while a child is pending on cold load', async () => {
+ let resolveChildLoader!: () => void
+ const childLoader = new Promise((resolve) => {
+ resolveChildLoader = resolve
+ })
+ const rootRenderValues: Array = []
+
+ const rootRoute = createRootRoute({
+ context: () => ({ locale: 'en' }),
+ component: () => {
+ const context = rootRoute.useRouteContext()
+ const locale = createMemo(() => {
+ const value: string | undefined = context().locale
+ rootRenderValues.push(value)
+ return value
+ })
+
+ return (
+
+ Locale: {locale() ?? 'missing'}
+
+
+ )
+ },
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ loader: () => childLoader,
+ pendingMs: 0,
+ pendingComponent: () => Loading child
,
+ component: () => Child content
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+
+ try {
+ expect(await screen.findByRole('status')).toHaveTextContent('Loading child')
+ expect(screen.getByTestId('root-locale')).toHaveTextContent('Locale: en')
+ expect(rootRenderValues.length).toBeGreaterThan(0)
+ expect(rootRenderValues.every((locale) => locale === 'en')).toBe(true)
+ } finally {
+ resolveChildLoader()
+ await childLoader
+ }
+})
+
+test('a same-id search navigation merges fresh inherited context with cached route context', async () => {
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ revision: typeof search.revision === 'string' ? search.revision : 'one',
+ }),
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ context: ({ deps }) => ({ inheritedRevision: deps.revision }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({}),
+ context: ({ context, location }) => {
+ const search = location.search as Record
+ return {
+ cachedSelfRevision: `${context.inheritedRevision}:${String(search.revision)}`,
+ }
+ },
+ component: () => {
+ const context = childRoute.useRouteContext()
+ const search = childRoute.useSearch()
+ const matchId = childRoute.useMatch({ select: (match) => match.id })
+
+ return (
+ <>
+ {matchId()}
+ {search().revision}
+
+ {context().inheritedRevision}
+
+
+ {context().cachedSelfRevision}
+
+ >
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?revision=one'],
+ }),
+ })
+
+ render(() => )
+
+ expect(await screen.findByTestId('current-search')).toHaveTextContent('one')
+ expect(screen.getByTestId('inherited-context')).toHaveTextContent('one')
+ expect(screen.getByTestId('cached-self-context')).toHaveTextContent('one:one')
+ const initialMatchId = screen.getByTestId('match-id').textContent
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { revision: 'two' },
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('current-search')).toHaveTextContent('two')
+ expect(screen.getByTestId('match-id').textContent).toBe(initialMatchId)
+ expect(screen.getByTestId('inherited-context')).toHaveTextContent('two')
+ expect(screen.getByTestId('cached-self-context')).toHaveTextContent(
+ 'one:one',
+ )
+ })
+})
diff --git a/packages/vue-router/src/RouterProvider.tsx b/packages/vue-router/src/RouterProvider.tsx
index 3a4fe63261..32222237e5 100644
--- a/packages/vue-router/src/RouterProvider.tsx
+++ b/packages/vue-router/src/RouterProvider.tsx
@@ -21,20 +21,20 @@ export const RouterContextProvider = Vue.defineComponent({
const router = props.router as AnyRouter
const restAttrs = attrs
- // Allow the router to update options on the router instance
- router.update({
- ...router.options,
- ...restAttrs,
- context: {
- ...router.options.context,
- ...(restAttrs.context || {}),
- },
- })
-
// Provide router to all child components
provideRouter(router)
return () => {
+ // Allow the router to update options on the router instance
+ router.update({
+ ...router.options,
+ ...restAttrs,
+ context: {
+ ...router.options.context,
+ ...(restAttrs.context || {}),
+ },
+ })
+
// Get child content
const childContent = slots.default?.()
@@ -90,10 +90,7 @@ export const RouterProvider = Vue.defineComponent({
} & Record,
): Vue.VNode
new (): {
- $props: {
- router: AnyRouter
- routeTree?: AnyRouter['routeTree']
- }
+ $props: RouterProps
}
}
diff --git a/packages/vue-router/tests/issue-8115-context.test.tsx b/packages/vue-router/tests/issue-8115-context.test.tsx
new file mode 100644
index 0000000000..6ba16737a7
--- /dev/null
+++ b/packages/vue-router/tests/issue-8115-context.test.tsx
@@ -0,0 +1,824 @@
+import * as Vue from 'vue'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/vue'
+import { afterEach, expect, test, vi } from 'vitest'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import {
+ Outlet,
+ RouterProvider,
+ Scripts,
+ createControlledPromise,
+ createMemoryHistory,
+ createRootRoute,
+ createRootRouteWithContext,
+ createRoute,
+ createRouter,
+ useLocation,
+} from '../src'
+import { createRequestHandler, renderRouterToString } from '../src/ssr/server'
+import type { AnyRouter } from '@tanstack/router-core'
+
+declare module '@tanstack/history' {
+ interface HistoryState {
+ issue8115Revision?: 'old' | 'new'
+ }
+}
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ delete window.$_TSR
+ delete (window as Window & { $R?: unknown }).$R
+ document.body.innerHTML = ''
+})
+
+test('invalidate merges fresh parent beforeLoad context with cached child context', async () => {
+ let generation = 0
+ let childContextCalls = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => {
+ generation++
+ return {
+ parentGeneration: generation,
+ collision: `parent-${generation}`,
+ }
+ },
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ context }) => {
+ childContextCalls++
+ return {
+ childSnapshotOfParent: context.parentGeneration,
+ collision: `child-snapshot-${context.parentGeneration}`,
+ }
+ },
+ component: () => {
+ const context = childRoute.useRouteContext()
+
+ return (
+
+
+ {context.value.parentGeneration}
+
+
+ {context.value.childSnapshotOfParent}
+
+
{context.value.collision}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByTestId('parent-generation')).toHaveTextContent('1')
+ expect(screen.getByTestId('child-snapshot')).toHaveTextContent('1')
+ expect(screen.getByTestId('collision')).toHaveTextContent('child-snapshot-1')
+ expect(generation).toBe(1)
+ expect(childContextCalls).toBe(1)
+ const childMatchId = router.state.matches[1]!.id
+
+ await router.invalidate()
+ await Vue.nextTick()
+
+ // The fresh parent contribution is merged under the cached child contribution.
+ expect(router.state.matches[1]!.id).toBe(childMatchId)
+ expect(generation).toBe(2)
+ expect(childContextCalls).toBe(1)
+ expect(screen.getByTestId('parent-generation')).toHaveTextContent('2')
+ expect(screen.getByTestId('child-snapshot')).toHaveTextContent('1')
+ expect(screen.getByTestId('collision')).toHaveTextContent('child-snapshot-1')
+})
+
+test('a same-id child beforeLoad error observes fresh inherited context', async () => {
+ const childError = new Error('child beforeLoad failed')
+ let parentGeneration = 0
+ let renderedError: unknown
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loaderDeps: () => ({ stable: true }),
+ beforeLoad: () => ({ generation: ++parentGeneration }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({ stable: true }),
+ beforeLoad: ({ context }) => {
+ if (context.generation === 2) {
+ throw childError
+ }
+ },
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+ {context.value.generation}
+ )
+ },
+ errorComponent: ({ error }) => {
+ renderedError = error
+ const context = childRoute.useRouteContext()
+
+ return (
+
+ {context.value.generation}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
+ })
+
+ render()
+
+ expect(await screen.findByTestId('child-generation')).toHaveTextContent('1')
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ const initialChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+ expect(initialChildMatchId).toBeDefined()
+
+ await router.invalidate()
+
+ expect(
+ await screen.findByTestId('child-error-generation'),
+ ).toBeInTheDocument()
+ expect(renderedError).toBe(childError)
+ expect(
+ router.state.matches.find((match) => match.routeId === childRoute.id)?.id,
+ ).toBe(initialChildMatchId)
+ expect(screen.getByTestId('child-error-generation')).toHaveTextContent('2')
+})
+
+test('a same-match reload merges new provider context with cached route context', async () => {
+ type ProviderContext = {
+ providerValue: string
+ collision: string
+ }
+
+ const providerA: ProviderContext = {
+ providerValue: 'A',
+ collision: 'provider:A',
+ }
+ const providerB: ProviderContext = {
+ providerValue: 'B',
+ collision: 'provider:B',
+ }
+ const rootRoute = createRootRouteWithContext()()
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ context }) => ({
+ derivedFromProvider: `derived:${context.providerValue}`,
+ collision: `route-cached:${context.providerValue}`,
+ }),
+ component: () => {
+ const context = indexRoute.useRouteContext()
+ return (
+ {JSON.stringify(context.value)}
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ context: providerA,
+ })
+ const providerContext = Vue.ref(providerA)
+ const App = Vue.defineComponent({
+ setup: () => () => (
+
+ ),
+ })
+
+ render(App)
+ expect(await screen.findByTestId('full-context')).toHaveTextContent(
+ JSON.stringify({
+ providerValue: 'A',
+ collision: 'route-cached:A',
+ derivedFromProvider: 'derived:A',
+ }),
+ )
+
+ providerContext.value = providerB
+ await Vue.nextTick()
+ await router.invalidate()
+ await Vue.nextTick()
+
+ expect(screen.getByTestId('full-context')).toHaveTextContent(
+ JSON.stringify({
+ providerValue: 'B',
+ collision: 'route-cached:A',
+ derivedFromProvider: 'derived:A',
+ }),
+ )
+})
+
+test('navigation merges fresh parent context with cached child preload context', async () => {
+ let parentBeforeLoadRuns = 0
+ let childContextRuns = 0
+ let childLoaderRuns = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: ({ preload }) => {
+ parentBeforeLoadRuns++
+ return {
+ parentValue: preload ? 'parent-preload' : 'parent-navigation',
+ }
+ },
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ context: ({ context, preload }) => {
+ childContextRuns++
+ return {
+ childValue: `${preload ? 'child-preload' : 'child-navigation'}:${context.parentValue}`,
+ }
+ },
+ loader: () => {
+ childLoaderRuns++
+ return 'child data'
+ },
+ preloadStaleTime: Infinity,
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ {JSON.stringify({
+ parentValue: context.value.parentValue,
+ childValue: context.value.childValue,
+ })}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+
+ await router.preloadRoute({ to: '/parent/child' })
+ expect(parentBeforeLoadRuns).toBe(1)
+ expect(childContextRuns).toBe(1)
+ expect(childLoaderRuns).toBe(1)
+
+ await router.navigate({ to: '/parent/child' })
+
+ expect(await screen.findByTestId('context')).toHaveTextContent(
+ JSON.stringify({
+ parentValue: 'parent-navigation',
+ childValue: 'child-preload:parent-preload',
+ }),
+ )
+ expect(parentBeforeLoadRuns).toBe(2)
+ expect(childContextRuns).toBe(1)
+ expect(childLoaderRuns).toBe(1)
+})
+
+test('a cached child context contribution is merged with fresh parent context', async () => {
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ version: Number(search.version),
+ }),
+ loaderDeps: ({ search }) => ({ version: search.version }),
+ context: ({ deps }) => ({ parentVersion: `version-${deps.version}` }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({ stable: true }),
+ context: ({ context }) => ({
+ childSnapshot: `derived-from-${context.parentVersion}`,
+ }),
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ Parent: {context.value.parentVersion}; cached child:{' '}
+ {context.value.childSnapshot}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?version=1'],
+ }),
+ })
+
+ render()
+
+ expect(
+ await screen.findByText(
+ 'Parent: version-1; cached child: derived-from-version-1',
+ ),
+ ).toBeInTheDocument()
+
+ const initialParentMatchId = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )?.id
+ const initialChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+
+ expect(initialParentMatchId).toBeDefined()
+ expect(initialChildMatchId).toBeDefined()
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { version: 2 },
+ })
+ await Vue.nextTick()
+
+ expect(
+ screen.getByText('Parent: version-2; cached child: derived-from-version-1'),
+ ).toBeInTheDocument()
+
+ const nextParentMatchId = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )?.id
+ const nextChildMatchId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+
+ expect(nextParentMatchId).not.toBe(initialParentMatchId)
+ expect(nextChildMatchId).toBe(initialChildMatchId)
+})
+
+test('#8115: hydration does not render a successful route with missing context when context reconstruction fails', async () => {
+ const contextError = new Error('client context reconstruction failed')
+ const clientSuccessRenderValues: Array = []
+ let clientContextAttempts = 0
+ let serverPhase = true
+
+ const createRouteTree = () => {
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: (): { locale: string } => {
+ if (serverPhase) {
+ return { locale: 'en' }
+ }
+ clientContextAttempts++
+ throw contextError
+ },
+ component: () => {
+ const context = indexRoute.useRouteContext({
+ select: (routeContext): { locale?: string } => routeContext,
+ })
+ if (!serverPhase) {
+ clientSuccessRenderValues.push(context.value.locale)
+ }
+ return (
+
+ Locale: {context.value.locale ?? 'missing'}
+
+ )
+ },
+ errorComponent: ({ error }) => (
+
+ {error instanceof Error ? error.message : String(error)}
+
+ ),
+ })
+
+ return rootRoute.addChildren([indexRoute])
+ }
+
+ const ServerApp = Vue.defineComponent({
+ props: {
+ router: {
+ type: Object as Vue.PropType,
+ required: true,
+ },
+ },
+ setup: (props) => () => (
+
+
+
+
+
+
+
+
+ ),
+ })
+
+ const response = await createRequestHandler({
+ request: new Request('http://localhost/'),
+ createRouter: () =>
+ createRouter({ routeTree: createRouteTree(), isServer: true }),
+ })(({ router, responseHeaders }) =>
+ renderRouterToString({
+ router,
+ responseHeaders,
+ App: ServerApp,
+ }),
+ )
+ const html = await response.text()
+ const serverDocument = new DOMParser().parseFromString(html, 'text/html')
+
+ expect(serverDocument.body.textContent).toContain('Locale: en')
+ const currentScriptSpy = vi.spyOn(document, 'currentScript', 'get')
+ try {
+ // These scripts come exclusively from this test's renderRouterToString output.
+ for (const script of serverDocument.querySelectorAll('script')) {
+ currentScriptSpy.mockReturnValue(script)
+ new Function(script.textContent ?? '')()
+ script.remove()
+ }
+ } finally {
+ currentScriptSpy.mockRestore()
+ }
+ expect(window.$_TSR?.router?.matches.at(-1)?.s).toBe('success')
+
+ serverPhase = false
+ const clientRouter = createRouter({
+ routeTree: createRouteTree(),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ const serverContainer = serverDocument.querySelector('#__app')
+ expect(serverContainer).not.toBeNull()
+ const container = document.createElement('div')
+ container.id = '__app'
+ container.innerHTML = serverContainer!.innerHTML
+ document.body.appendChild(container)
+ const hydrationMessages: Array = []
+ const originalError = console.error.bind(console)
+ const originalWarn = console.warn.bind(console)
+ const recordHydrationMessage = (...args: Array) => {
+ const message = args.map(String).join(' ')
+ if (/hydration|mismatch/i.test(message)) {
+ hydrationMessages.push(message)
+ return true
+ }
+ return false
+ }
+ vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (!recordHydrationMessage(...args)) {
+ originalError(...args)
+ }
+ })
+ vi.spyOn(console, 'warn').mockImplementation((...args) => {
+ if (!recordHydrationMessage(...args)) {
+ originalWarn(...args)
+ }
+ })
+ let app: Vue.App | undefined
+
+ try {
+ await hydrate(clientRouter)
+ const ClientApp = Vue.defineComponent({
+ setup: () => () => ,
+ })
+ app = Vue.createSSRApp(ClientApp)
+ app.mount(container)
+ await Vue.nextTick()
+
+ await waitFor(() => {
+ expect(
+ container.querySelector('[data-testid="route-error"]'),
+ ).toHaveTextContent(contextError.message)
+ })
+ expect(clientContextAttempts).toBeGreaterThan(0)
+ expect(container.querySelector('[data-testid="route-success"]')).toBeNull()
+ expect(clientSuccessRenderValues).not.toContain(undefined)
+ expect(hydrationMessages.length).toBeGreaterThan(0)
+ } finally {
+ app?.unmount()
+ container.remove()
+ }
+})
+
+test('a same-id child retry presents one coherent beforeLoad context generation', async () => {
+ const childReloadStarted = createControlledPromise()
+ const childReload = createControlledPromise()
+ let parentGeneration = 0
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: () => ({ generation: ++parentGeneration }),
+ component: () => {
+ const context = parentRoute.useRouteContext()
+ return (
+
+
+ Parent generation {context.value.generation}
+
+
+
+ )
+ },
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ beforeLoad: ({ context }) => ({
+ inheritedGeneration: context.generation,
+ }),
+ loader: async () => {
+ if (++childLoads > 1) {
+ childReloadStarted.resolve()
+ await childReload
+ }
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ Child generation {context.value.inheritedGeneration}
+
+ )
+ },
+ component: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ Child generation {context.value.inheritedGeneration}
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({ initialEntries: ['/parent/child'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('parent-generation')).toHaveTextContent(
+ 'Parent generation 1',
+ )
+ expect(await screen.findByTestId('child-generation')).toHaveTextContent(
+ 'Child generation 1',
+ )
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ const initialChildId = router.state.matches.find(
+ (match) => match.routeId === childRoute.id,
+ )?.id
+ expect(initialChildId).toBeDefined()
+
+ let invalidation: Promise | undefined
+ try {
+ invalidation = router.invalidate({
+ filter: (match) =>
+ match.routeId === parentRoute.id || match.routeId === childRoute.id,
+ forcePending: true,
+ })
+ await childReloadStarted
+
+ await waitFor(() => {
+ expect(screen.getByTestId('parent-generation')).toHaveTextContent(
+ 'Parent generation 2',
+ )
+ expect(screen.getByTestId('child-pending-generation')).toHaveTextContent(
+ 'Child generation 2',
+ )
+ })
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'success',
+ context: { generation: 2 },
+ })
+ expect(
+ router.state.matches.find((match) => match.routeId === childRoute.id),
+ ).toMatchObject({
+ id: initialChildId,
+ status: 'pending',
+ context: { generation: 2, inheritedGeneration: 2 },
+ })
+ } finally {
+ childReload.resolve()
+ await Promise.allSettled(invalidation ? [invalidation] : [])
+ }
+})
+
+test('a same-id navigation merges new inherited context with cached route context', async () => {
+ const history = createMemoryHistory({ initialEntries: ['/'] })
+ history.replace('/', { issue8115Revision: 'old' })
+
+ const rootRoute = createRootRoute({
+ beforeLoad: ({ location }) => ({
+ inheritedRevision: location.state.issue8115Revision,
+ }),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ context: ({ location }) => ({
+ selfRevision: location.state.issue8115Revision,
+ }),
+ component: () => {
+ const location = useLocation()
+ const context = indexRoute.useRouteContext()
+ const matchId = indexRoute.useMatch({ select: (match) => match.id })
+ const navigate = indexRoute.useNavigate()
+
+ return (
+ <>
+
+
+
+ >
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history,
+ })
+
+ render()
+
+ expect(await screen.findByTestId('snapshot')).toHaveTextContent(
+ 'location: old; inherited: old; self: old',
+ )
+ const initialMatchId = screen.getByTestId('match-id').textContent
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Update state' }))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('match-id').textContent).toBe(initialMatchId)
+ expect(screen.getByTestId('snapshot')).toHaveTextContent(
+ 'location: new; inherited: new; self: old',
+ )
+ })
+})
+
+test('#8115: a successful root never renders without its context while a child is pending on cold load', async () => {
+ let resolveChildLoader!: () => void
+ const childLoader = new Promise((resolve) => {
+ resolveChildLoader = resolve
+ })
+ const rootRenderValues: Array = []
+
+ const rootRoute = createRootRoute({
+ context: () => ({ locale: 'en' }),
+ component: () => {
+ const context = rootRoute.useRouteContext()
+ const locale = context.value.locale
+ rootRenderValues.push(locale)
+
+ return (
+
+ Locale: {locale ?? 'missing'}
+
+
+ )
+ },
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ loader: () => childLoader,
+ pendingMs: 0,
+ pendingComponent: () => Loading child
,
+ component: () => Child content
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ try {
+ expect(await screen.findByRole('status')).toHaveTextContent('Loading child')
+ expect(screen.getByTestId('root-locale')).toHaveTextContent('Locale: en')
+ expect(rootRenderValues.length).toBeGreaterThan(0)
+ expect(rootRenderValues.every((locale) => locale === 'en')).toBe(true)
+ } finally {
+ resolveChildLoader()
+ await childLoader
+ await Vue.nextTick()
+ }
+})
+
+test('a same-id search navigation merges fresh inherited context with cached route context', async () => {
+ const rootRoute = createRootRoute({ component: Outlet })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ revision: typeof search.revision === 'string' ? search.revision : 'one',
+ }),
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ context: ({ deps }) => ({ inheritedRevision: deps.revision }),
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: () => ({}),
+ context: ({ context, location }) => ({
+ cachedSelfRevision: `${context.inheritedRevision}:${String((location.search as Record).revision)}`,
+ }),
+ component: () => {
+ const context = childRoute.useRouteContext()
+ const search = childRoute.useSearch()
+ const matchId = childRoute.useMatch({ select: (match) => match.id })
+
+ return (
+ <>
+ {matchId.value}
+ {search.value.revision}
+
+ {context.value.inheritedRevision}
+
+
+ {context.value.cachedSelfRevision}
+
+ >
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?revision=one'],
+ }),
+ })
+
+ render()
+
+ expect(await screen.findByTestId('current-search')).toHaveTextContent('one')
+ expect(screen.getByTestId('inherited-context')).toHaveTextContent('one')
+ expect(screen.getByTestId('cached-self-context')).toHaveTextContent('one:one')
+ const initialMatchId = screen.getByTestId('match-id').textContent
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { revision: 'two' },
+ })
+ await Vue.nextTick()
+
+ expect(await screen.findByTestId('current-search')).toHaveTextContent('two')
+ expect(screen.getByTestId('match-id').textContent).toBe(initialMatchId)
+ expect(screen.getByTestId('inherited-context')).toHaveTextContent('two')
+ expect(screen.getByTestId('cached-self-context')).toHaveTextContent('one:one')
+})