From 3f02fdeca66b5d607ceda1bedfea1063e87e3249 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 14:48:36 +0200 Subject: [PATCH 01/11] fix(router-core): preserve context during reloads --- ...ue-8115-beforeload-context-window.test.tsx | 57 +++++++ ...sue-8115-beforeload-error-context.test.tsx | 76 +++++++++ ...d-child-parent-beforeload-context.test.tsx | 76 +++++++++ ...-cached-child-parent-deps-context.test.tsx | 91 +++++++++++ ...8115-cached-child-preload-context.test.tsx | 86 ++++++++++ ...ached-route-history-state-context.test.tsx | 84 ++++++++++ ...115-cached-route-provider-context.test.tsx | 69 ++++++++ ...-8115-cached-route-search-context.test.tsx | 85 ++++++++++ .../issue-8115-cold-pending-context.test.tsx | 63 ++++++++ ...ue-8115-context-error-inheritance.test.tsx | 69 ++++++++ ...ue-8115-hydration-context-failure.test.tsx | 153 ++++++++++++++++++ ...-8115-same-id-child-retry-context.test.tsx | 120 ++++++++++++++ packages/router-core/src/load-client.ts | 20 ++- 13 files changed, 1045 insertions(+), 4 deletions(-) create mode 100644 packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx create mode 100644 packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-cold-pending-context.test.tsx create mode 100644 packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx create mode 100644 packages/react-router/tests/issue-8115-hydration-context-failure.test.tsx create mode 100644 packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx diff --git a/packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx b/packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx new file mode 100644 index 0000000000..238501d3dc --- /dev/null +++ b/packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx @@ -0,0 +1,57 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { + const reload = createControlledPromise() + const reloadStarted = createControlledPromise() + const observedContexts: Array = [] + let beforeLoadRuns = 0 + + const rootRoute = createRootRoute({ + beforeLoad: async ({ matches }) => { + beforeLoadRuns++ + if (beforeLoadRuns > 1) { + observedContexts.push(matches[0]?.context) + reloadStarted.resolve() + await reload + } + return { locale: 'en' } + }, + component: () => { + const { locale } = rootRoute.useRouteContext() + return
{locale ?? 'missing'}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByTestId('locale')).toHaveTextContent('en') + + let invalidation!: Promise + await act(async () => { + invalidation = router.invalidate() + await reloadStarted + }) + + expect(beforeLoadRuns).toBe(2) + expect(screen.getByTestId('locale')).toHaveTextContent('en') + + reload.resolve() + await act(() => invalidation) + + expect(observedContexts).toEqual([{ locale: 'en' }]) +}) diff --git a/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx b/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx new file mode 100644 index 0000000000..dba45ece72 --- /dev/null +++ b/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx @@ -0,0 +1,76 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +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') +}) diff --git a/packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx b/packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx new file mode 100644 index 0000000000..d88292573f --- /dev/null +++ b/packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx @@ -0,0 +1,76 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +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') +}) diff --git a/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx b/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx new file mode 100644 index 0000000000..0e675e56b8 --- /dev/null +++ b/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx @@ -0,0 +1,91 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +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) +}) diff --git a/packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx b/packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx new file mode 100644 index 0000000000..446a2fde89 --- /dev/null +++ b/packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx @@ -0,0 +1,86 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +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) +}) diff --git a/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx new file mode 100644 index 0000000000..e213671ba8 --- /dev/null +++ b/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useLocation, +} from '../src' + +declare module '@tanstack/history' { + interface HistoryState { + issue8115Revision?: 'old' | 'new' + } +} + +afterEach(() => { + cleanup() +}) + +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 ( + <> + {matchId} + + location: {location.state.issue8115Revision}; inherited:{' '} + {context.inheritedRevision}; self: {context.selfRevision} + + + + ) + }, + }) + 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', + ) + }) +}) diff --git a/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx new file mode 100644 index 0000000000..3a49340d3e --- /dev/null +++ b/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx @@ -0,0 +1,69 @@ +import { act } from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + RouterProvider, + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +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', + }), + ) +}) diff --git a/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx new file mode 100644 index 0000000000..cd1f87af44 --- /dev/null +++ b/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx @@ -0,0 +1,85 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +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.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/react-router/tests/issue-8115-cold-pending-context.test.tsx b/packages/react-router/tests/issue-8115-cold-pending-context.test.tsx new file mode 100644 index 0000000000..30a0d0987c --- /dev/null +++ b/packages/react-router/tests/issue-8115-cold-pending-context.test.tsx @@ -0,0 +1,63 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +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 + }) + } +}) diff --git a/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx b/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx new file mode 100644 index 0000000000..6d3c61f51d --- /dev/null +++ b/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('a child context error preserves inherited context without the child contribution', async () => { + const contextError = new Error('child context failed') + const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ + context: () => ({ rootValue: 'root' }), + component: Outlet, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentValue: 'parent' }), + component: Outlet, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: (): { childValue: string } => { + throw contextError + }, + errorComponent: ({ error }) => { + const context = childRoute.useRouteContext() + + return ( +
+
+ {error === contextError ? contextError.message : 'unexpected error'} +
+
{context.routerValue}
+
{context.rootValue}
+
{context.parentValue}
+
+ {'childValue' in context ? context.childValue : 'absent'} +
+
+ ) + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + context: { routerValue: 'router' }, + }) + + render() + + expect(await screen.findByTestId('route-error')).toHaveTextContent( + contextError.message, + ) + expect(screen.getByTestId('router-context')).toHaveTextContent('router') + expect(screen.getByTestId('root-context')).toHaveTextContent('root') + expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') + expect(screen.getByTestId('child-context')).toHaveTextContent('absent') +}) diff --git a/packages/react-router/tests/issue-8115-hydration-context-failure.test.tsx b/packages/react-router/tests/issue-8115-hydration-context-failure.test.tsx new file mode 100644 index 0000000000..308ec55afb --- /dev/null +++ b/packages/react-router/tests/issue-8115-hydration-context-failure.test.tsx @@ -0,0 +1,153 @@ +import { act, waitFor } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { afterEach, expect, test, vi } from 'vitest' +import { hydrate } from '../src/ssr/client' +import { + RouterServer, + createRequestHandler, + renderRouterToString, +} from '../src/ssr/server' +import { + Outlet, + RouterProvider, + Scripts, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + vi.restoreAllMocks() + delete window.$_TSR + delete (window as any).$R + document.body.innerHTML = '' +}) + +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 { + 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) => { + if ( + error instanceof Error && + error.message.startsWith( + "Hydration failed because the server rendered HTML didn't match the client.", + ) + ) { + 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).toHaveLength(1) + } finally { + if (root) { + await act(() => root.unmount()) + } + container.remove() + } +}) diff --git a/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx b/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx new file mode 100644 index 0000000000..c581b76230 --- /dev/null +++ b/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx @@ -0,0 +1,120 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +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] : []) + }) + } +}) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 71c97a63ab..ca624da62f 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -394,6 +394,7 @@ async function contextualize( } match.context = context } catch (cause) { + match.context = parentContext releaseFlight(router, match) return [index, normalizeLaneError(router, lane, route, cause, options)] } @@ -413,6 +414,12 @@ async function contextualize( continue } + const base = options[2 /* base */][index] + // Keep the committed context observable until beforeLoad settles. + if (base?.id === match.id) { + match.context = base.context + } + const beforeLoadContext: BeforeLoadContextOptions< any, any, @@ -452,7 +459,7 @@ async function contextualize( releaseFlight(router, match) return [index, outcome] } - match.context = { + context = { ...context, ...result, } @@ -460,6 +467,7 @@ async function contextualize( releaseFlight(router, match) return [index, normalizeLaneError(router, lane, route, cause, options)] } finally { + match.context = context if (match.status === 'pending') { match.status = previousStatus } @@ -2038,11 +2046,14 @@ 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. Child boundaries wait for + // contextualization to publish through onReady; 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) } @@ -2229,6 +2240,7 @@ export async function hydrate(router: AnyRouter): Promise { let pendingBoundary: number | undefined let verifiedAssetEnd = 0 const retryFrom = (index: number) => { + pendingBoundary = Math.min(pendingBoundary ?? index, index) // The failing route's identity is still verified, but no descendant is. verifiedAssetEnd = Math.min(verifiedAssetEnd, index + 1) const removed = committed.splice(index) From a3117f94ba27e4d1ab4b7f73a7a3a384551c2779 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:50:58 +0000 Subject: [PATCH 02/11] ci: apply automated fixes --- .../issue-8115-beforeload-error-context.test.tsx | 8 ++++---- ...115-cached-child-parent-deps-context.test.tsx | 4 +--- ...5-cached-route-history-state-context.test.tsx | 8 +++++++- ...e-8115-cached-route-provider-context.test.tsx | 4 +--- ...sue-8115-cached-route-search-context.test.tsx | 16 ++++------------ ...issue-8115-context-error-inheritance.test.tsx | 4 +--- ...sue-8115-same-id-child-retry-context.test.tsx | 8 ++------ 7 files changed, 20 insertions(+), 32 deletions(-) diff --git a/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx b/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx index dba45ece72..c2cabfe9ae 100644 --- a/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx +++ b/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx @@ -50,9 +50,7 @@ test('a same-id child beforeLoad error observes fresh inherited context', async }, }) const router = createRouter({ - routeTree: rootRoute.addChildren([ - parentRoute.addChildren([childRoute]), - ]), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory({ initialEntries: ['/parent/child'] }), }) @@ -67,7 +65,9 @@ test('a same-id child beforeLoad error observes fresh inherited context', async await act(() => router.invalidate()) - expect(await screen.findByTestId('child-error-generation')).toBeInTheDocument() + expect( + await screen.findByTestId('child-error-generation'), + ).toBeInTheDocument() expect(renderedError).toBe(childError) expect( router.state.matches.find((match) => match.routeId === childRoute.id)?.id, diff --git a/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx b/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx index 0e675e56b8..72287bfd6c 100644 --- a/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx +++ b/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx @@ -74,9 +74,7 @@ test('a cached child context contribution is merged with fresh parent context', ) expect( - screen.getByText( - 'Parent: version-2; cached child: derived-from-version-1', - ), + screen.getByText('Parent: version-2; cached child: derived-from-version-1'), ).toBeInTheDocument() const nextParentMatchId = router.state.matches.find( diff --git a/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx index e213671ba8..86da4ba50e 100644 --- a/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx +++ b/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' import { afterEach, expect, test } from 'vitest' import { RouterProvider, diff --git a/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx index 3a49340d3e..507aabc80c 100644 --- a/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx +++ b/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx @@ -45,9 +45,7 @@ test('a same-match reload merges new provider context with cached route context' context: providerA, }) - const view = render( - , - ) + const view = render() expect(await screen.findByTestId('full-context')).toHaveTextContent( JSON.stringify({ providerValue: 'A', diff --git a/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx index cd1f87af44..d81146c1e6 100644 --- a/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx +++ b/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx @@ -41,9 +41,7 @@ test('a same-id search navigation merges fresh inherited context with cached rou <>
{matchId}
{search.revision}
-
- {context.inheritedRevision} -
+
{context.inheritedRevision}
{context.cachedSelfRevision}
@@ -52,9 +50,7 @@ test('a same-id search navigation merges fresh inherited context with cached rou }, }) const router = createRouter({ - routeTree: rootRoute.addChildren([ - parentRoute.addChildren([childRoute]), - ]), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory({ initialEntries: ['/parent/child?revision=one'], }), @@ -64,9 +60,7 @@ test('a same-id search navigation merges fresh inherited context with cached rou expect(await screen.findByTestId('current-search')).toHaveTextContent('one') expect(screen.getByTestId('inherited-context')).toHaveTextContent('one') - expect(screen.getByTestId('cached-self-context')).toHaveTextContent( - 'one:one', - ) + expect(screen.getByTestId('cached-self-context')).toHaveTextContent('one:one') const initialMatchId = screen.getByTestId('match-id').textContent await act(() => @@ -79,7 +73,5 @@ test('a same-id search navigation merges fresh inherited context with cached rou 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', - ) + expect(screen.getByTestId('cached-self-context')).toHaveTextContent('one:one') }) diff --git a/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx b/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx index 6d3c61f51d..bf8cdad996 100644 --- a/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx +++ b/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx @@ -50,9 +50,7 @@ test('a child context error preserves inherited context without the child contri }, }) const router = createRouter({ - routeTree: rootRoute.addChildren([ - parentRoute.addChildren([childRoute]), - ]), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory({ initialEntries: ['/parent/child'] }), context: { routerValue: 'router' }, }) diff --git a/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx b/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx index c581b76230..ec7f956503 100644 --- a/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx +++ b/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx @@ -60,9 +60,7 @@ test('a same-id child retry presents one coherent beforeLoad context generation' ), }) const router = createRouter({ - routeTree: rootRoute.addChildren([ - parentRoute.addChildren([childRoute]), - ]), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory({ initialEntries: ['/parent/child'] }), }) @@ -97,9 +95,7 @@ test('a same-id child retry presents one coherent beforeLoad context generation' 'Child generation 2', ) expect( - router.state.matches.find( - (match) => match.routeId === parentRoute.id, - ), + router.state.matches.find((match) => match.routeId === parentRoute.id), ).toMatchObject({ status: 'success', context: { generation: 2 }, From e3a20b8d93a292985a58dd046ac2541b128c1b5d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 16:01:59 +0200 Subject: [PATCH 03/11] test(router): consolidate issue 8115 coverage --- ...ue-8115-beforeload-context-window.test.tsx | 57 -- ...sue-8115-beforeload-error-context.test.tsx | 76 -- ...d-child-parent-beforeload-context.test.tsx | 76 -- ...-cached-child-parent-deps-context.test.tsx | 89 -- ...8115-cached-child-preload-context.test.tsx | 86 -- ...ached-route-history-state-context.test.tsx | 90 -- ...115-cached-route-provider-context.test.tsx | 67 -- ...-8115-cached-route-search-context.test.tsx | 77 -- .../issue-8115-cold-pending-context.test.tsx | 63 -- ...ue-8115-context-error-inheritance.test.tsx | 67 -- .../tests/issue-8115-context.test.tsx | 872 +++++++++++++++++ ...ue-8115-hydration-context-failure.test.tsx | 153 --- ...-8115-same-id-child-retry-context.test.tsx | 116 --- .../tests/issue-8115-context.test.tsx | 860 ++++++++++++++++ .../tests/issue-8115-context.test.tsx | 914 ++++++++++++++++++ 15 files changed, 2646 insertions(+), 1017 deletions(-) delete mode 100644 packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-cold-pending-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx create mode 100644 packages/react-router/tests/issue-8115-context.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-hydration-context-failure.test.tsx delete mode 100644 packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx create mode 100644 packages/solid-router/tests/issue-8115-context.test.tsx create mode 100644 packages/vue-router/tests/issue-8115-context.test.tsx diff --git a/packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx b/packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx deleted file mode 100644 index 238501d3dc..0000000000 --- a/packages/react-router/tests/issue-8115-beforeload-context-window.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - RouterProvider, - createControlledPromise, - createMemoryHistory, - createRootRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { - const reload = createControlledPromise() - const reloadStarted = createControlledPromise() - const observedContexts: Array = [] - let beforeLoadRuns = 0 - - const rootRoute = createRootRoute({ - beforeLoad: async ({ matches }) => { - beforeLoadRuns++ - if (beforeLoadRuns > 1) { - observedContexts.push(matches[0]?.context) - reloadStarted.resolve() - await reload - } - return { locale: 'en' } - }, - component: () => { - const { locale } = rootRoute.useRouteContext() - return
{locale ?? 'missing'}
- }, - }) - const router = createRouter({ - routeTree: rootRoute, - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - render() - expect(await screen.findByTestId('locale')).toHaveTextContent('en') - - let invalidation!: Promise - await act(async () => { - invalidation = router.invalidate() - await reloadStarted - }) - - expect(beforeLoadRuns).toBe(2) - expect(screen.getByTestId('locale')).toHaveTextContent('en') - - reload.resolve() - await act(() => invalidation) - - expect(observedContexts).toEqual([{ locale: 'en' }]) -}) diff --git a/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx b/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx deleted file mode 100644 index c2cabfe9ae..0000000000 --- a/packages/react-router/tests/issue-8115-beforeload-error-context.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -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') -}) diff --git a/packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx b/packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx deleted file mode 100644 index d88292573f..0000000000 --- a/packages/react-router/tests/issue-8115-cached-child-parent-beforeload-context.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -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') -}) diff --git a/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx b/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx deleted file mode 100644 index 72287bfd6c..0000000000 --- a/packages/react-router/tests/issue-8115-cached-child-parent-deps-context.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -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) -}) diff --git a/packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx b/packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx deleted file mode 100644 index 446a2fde89..0000000000 --- a/packages/react-router/tests/issue-8115-cached-child-preload-context.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(cleanup) - -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) -}) diff --git a/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx deleted file mode 100644 index 86da4ba50e..0000000000 --- a/packages/react-router/tests/issue-8115-cached-route-history-state-context.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - useLocation, -} from '../src' - -declare module '@tanstack/history' { - interface HistoryState { - issue8115Revision?: 'old' | 'new' - } -} - -afterEach(() => { - cleanup() -}) - -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 ( - <> - {matchId} - - location: {location.state.issue8115Revision}; inherited:{' '} - {context.inheritedRevision}; self: {context.selfRevision} - - - - ) - }, - }) - 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', - ) - }) -}) diff --git a/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx deleted file mode 100644 index 507aabc80c..0000000000 --- a/packages/react-router/tests/issue-8115-cached-route-provider-context.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { act } from 'react' -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - RouterProvider, - createMemoryHistory, - createRootRouteWithContext, - createRoute, - createRouter, -} from '../src' - -afterEach(cleanup) - -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', - }), - ) -}) diff --git a/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx b/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx deleted file mode 100644 index d81146c1e6..0000000000 --- a/packages/react-router/tests/issue-8115-cached-route-search-context.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -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.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/react-router/tests/issue-8115-cold-pending-context.test.tsx b/packages/react-router/tests/issue-8115-cold-pending-context.test.tsx deleted file mode 100644 index 30a0d0987c..0000000000 --- a/packages/react-router/tests/issue-8115-cold-pending-context.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -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 - }) - } -}) diff --git a/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx b/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx deleted file mode 100644 index bf8cdad996..0000000000 --- a/packages/react-router/tests/issue-8115-context-error-inheritance.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRouteWithContext, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -test('a child context error preserves inherited context without the child contribution', async () => { - const contextError = new Error('child context failed') - const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ - context: () => ({ rootValue: 'root' }), - component: Outlet, - }) - const parentRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/parent', - context: () => ({ parentValue: 'parent' }), - component: Outlet, - }) - const childRoute = createRoute({ - getParentRoute: () => parentRoute, - path: '/child', - context: (): { childValue: string } => { - throw contextError - }, - errorComponent: ({ error }) => { - const context = childRoute.useRouteContext() - - return ( -
-
- {error === contextError ? contextError.message : 'unexpected error'} -
-
{context.routerValue}
-
{context.rootValue}
-
{context.parentValue}
-
- {'childValue' in context ? context.childValue : 'absent'} -
-
- ) - }, - }) - const router = createRouter({ - routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), - history: createMemoryHistory({ initialEntries: ['/parent/child'] }), - context: { routerValue: 'router' }, - }) - - render() - - expect(await screen.findByTestId('route-error')).toHaveTextContent( - contextError.message, - ) - expect(screen.getByTestId('router-context')).toHaveTextContent('router') - expect(screen.getByTestId('root-context')).toHaveTextContent('root') - expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') - expect(screen.getByTestId('child-context')).toHaveTextContent('absent') -}) 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..72e54c3e35 --- /dev/null +++ b/packages/react-router/tests/issue-8115-context.test.tsx @@ -0,0 +1,872 @@ +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 any).$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('a child context error preserves inherited context without the child contribution', async () => { + const contextError = new Error('child context failed') + const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ + context: () => ({ rootValue: 'root' }), + component: Outlet, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentValue: 'parent' }), + component: Outlet, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: (): { childValue: string } => { + throw contextError + }, + errorComponent: ({ error }) => { + const context = childRoute.useRouteContext() + + return ( +
+
+ {error === contextError ? contextError.message : 'unexpected error'} +
+
{context.routerValue}
+
{context.rootValue}
+
{context.parentValue}
+
+ {'childValue' in context ? context.childValue : 'absent'} +
+
+ ) + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + context: { routerValue: 'router' }, + }) + + render() + + expect(await screen.findByTestId('route-error')).toHaveTextContent( + contextError.message, + ) + expect(screen.getByTestId('router-context')).toHaveTextContent('router') + expect(screen.getByTestId('root-context')).toHaveTextContent('root') + expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') + expect(screen.getByTestId('child-context')).toHaveTextContent('absent') +}) + +test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { + const reload = createControlledPromise() + const reloadStarted = createControlledPromise() + const observedContexts: Array = [] + let beforeLoadRuns = 0 + + const rootRoute = createRootRoute({ + beforeLoad: async ({ matches }) => { + beforeLoadRuns++ + if (beforeLoadRuns > 1) { + observedContexts.push(matches[0]?.context) + reloadStarted.resolve() + await reload + } + return { locale: 'en' } + }, + component: () => { + const { locale } = rootRoute.useRouteContext() + return
{locale ?? 'missing'}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByTestId('locale')).toHaveTextContent('en') + + let invalidation!: Promise + await act(async () => { + invalidation = router.invalidate() + await reloadStarted + }) + + expect(beforeLoadRuns).toBe(2) + expect(screen.getByTestId('locale')).toHaveTextContent('en') + + reload.resolve() + await act(() => invalidation) + + expect(observedContexts).toEqual([{ locale: 'en' }]) +}) + +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 { + 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) => { + if ( + error instanceof Error && + error.message.startsWith( + "Hydration failed because the server rendered HTML didn't match the client.", + ) + ) { + 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).toHaveLength(1) + } 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: () => { + const location = useLocation() + const context = indexRoute.useRouteContext() + const matchId = indexRoute.useMatch({ select: (match) => match.id }) + const navigate = indexRoute.useNavigate() + + return ( + <> + {matchId} + + location: {location.state.issue8115Revision}; inherited:{' '} + {context.inheritedRevision}; self: {context.selfRevision} + + + + ) + }, + }) + 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.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/react-router/tests/issue-8115-hydration-context-failure.test.tsx b/packages/react-router/tests/issue-8115-hydration-context-failure.test.tsx deleted file mode 100644 index 308ec55afb..0000000000 --- a/packages/react-router/tests/issue-8115-hydration-context-failure.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { act, waitFor } from '@testing-library/react' -import { hydrateRoot } from 'react-dom/client' -import { afterEach, expect, test, vi } from 'vitest' -import { hydrate } from '../src/ssr/client' -import { - RouterServer, - createRequestHandler, - renderRouterToString, -} from '../src/ssr/server' -import { - Outlet, - RouterProvider, - Scripts, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - vi.restoreAllMocks() - delete window.$_TSR - delete (window as any).$R - document.body.innerHTML = '' -}) - -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 { - 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) => { - if ( - error instanceof Error && - error.message.startsWith( - "Hydration failed because the server rendered HTML didn't match the client.", - ) - ) { - 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).toHaveLength(1) - } finally { - if (root) { - await act(() => root.unmount()) - } - container.remove() - } -}) diff --git a/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx b/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx deleted file mode 100644 index ec7f956503..0000000000 --- a/packages/react-router/tests/issue-8115-same-id-child-retry-context.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' -import { - Outlet, - RouterProvider, - createControlledPromise, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' - -afterEach(() => { - cleanup() -}) - -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] : []) - }) - } -}) 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..72d4c4ff5f --- /dev/null +++ b/packages/solid-router/tests/issue-8115-context.test.tsx @@ -0,0 +1,860 @@ +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 { + Outlet, + RouterProvider, + Scripts, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRootRouteWithContext, + createRoute, + createRouter, + useLocation, +} from '../src' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +declare module '@tanstack/history' { + interface HistoryState { + issue8115Revision?: 'old' | 'new' + } +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + delete window.$_TSR + delete (window as any).$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) + 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() + + expect(screen.getByTestId('full-context')).toHaveTextContent( + JSON.stringify({ + providerValue: 'B', + collision: 'route-cached:A', + derivedFromProvider: 'derived:A', + }), + ) +}) + +test('a child context error preserves inherited context without the child contribution', async () => { + const contextError = new Error('child context failed') + const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ + context: () => ({ rootValue: 'root' }), + component: Outlet, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentValue: 'parent' }), + component: Outlet, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: (): { childValue: string } => { + throw contextError + }, + errorComponent: ({ error }) => { + const context = childRoute.useRouteContext() + + return ( +
+
+ {error === contextError ? contextError.message : 'unexpected error'} +
+
{context().routerValue}
+
{context().rootValue}
+
{context().parentValue}
+
+ {'childValue' in context() ? context().childValue : 'absent'} +
+
+ ) + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + context: { routerValue: 'router' }, + }) + + render(() => ) + + expect(await screen.findByTestId('route-error')).toHaveTextContent( + contextError.message, + ) + expect(screen.getByTestId('router-context')).toHaveTextContent('router') + expect(screen.getByTestId('root-context')).toHaveTextContent('root') + expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') + expect(screen.getByTestId('child-context')).toHaveTextContent('absent') +}) + +test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { + const reload = createControlledPromise() + const reloadStarted = createControlledPromise() + const observedContexts: Array = [] + let beforeLoadRuns = 0 + + const rootRoute = createRootRoute({ + beforeLoad: async ({ matches }) => { + beforeLoadRuns++ + if (beforeLoadRuns > 1) { + observedContexts.push(matches[0]?.context) + reloadStarted.resolve() + await reload + } + return { locale: 'en' } + }, + component: () => { + const context = rootRoute.useRouteContext() + return
{context().locale ?? 'missing'}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByTestId('locale')).toHaveTextContent('en') + + let invalidation!: Promise + try { + invalidation = router.invalidate() + await reloadStarted + + expect(beforeLoadRuns).toBe(2) + expect(screen.getByTestId('locale')).toHaveTextContent('en') + + reload.resolve() + await invalidation + + expect(observedContexts).toEqual([{ locale: 'en' }]) + } finally { + reload.resolve() + await Promise.allSettled(invalidation ? [invalidation] : []) + } +}) + +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 }, + }) + + 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, + }) + await serverRouter.load() + expect(serverRouter.state.matches.at(-1)?.context).toMatchObject({ + locale: 'en', + }) + expect(serverRouter.state.matches.at(-1)?.status).toBe('success') + + window.$_TSR = { + router: { + manifest: undefined, + matches: serverRouter.state.matches.map((match) => ({ + i: match.id + .replaceAll('~', '~~') + .replaceAll('\0', '~0') + .replaceAll('\uFFFD', '~r') + .replaceAll('/', '\0'), + s: match.status, + ssr: true, + u: match.updatedAt, + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + } as TsrSsrGlobal + + 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 ( + <> + {matchId()} + + location: {location().state.issue8115Revision}; inherited:{' '} + {context().inheritedRevision}; self: {context().selfRevision} + + + + ) + }, + }) + 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' }, + }) + + 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/vue-router/tests/issue-8115-context.test.tsx b/packages/vue-router/tests/issue-8115-context.test.tsx new file mode 100644 index 0000000000..fe1833f964 --- /dev/null +++ b/packages/vue-router/tests/issue-8115-context.test.tsx @@ -0,0 +1,914 @@ +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 any).$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('a child context error preserves inherited context without the child contribution', async () => { + const contextError = new Error('child context failed') + const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ + context: () => ({ rootValue: 'root' }), + component: Outlet, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentValue: 'parent' }), + component: Outlet, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: (): { childValue: string } => { + throw contextError + }, + errorComponent: ({ error }) => { + const context = childRoute.useRouteContext() + + return ( +
+
+ {error === contextError ? contextError.message : 'unexpected error'} +
+
{context.value.routerValue}
+
{context.value.rootValue}
+
{context.value.parentValue}
+
+ {'childValue' in context.value + ? context.value.childValue + : 'absent'} +
+
+ ) + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + context: { routerValue: 'router' }, + }) + + render() + + expect(await screen.findByTestId('route-error')).toHaveTextContent( + contextError.message, + ) + expect(screen.getByTestId('router-context')).toHaveTextContent('router') + expect(screen.getByTestId('root-context')).toHaveTextContent('root') + expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') + expect(screen.getByTestId('child-context')).toHaveTextContent('absent') +}) + +test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { + const reload = createControlledPromise() + const reloadStarted = createControlledPromise() + const observedContexts: Array = [] + let beforeLoadRuns = 0 + + const rootRoute = createRootRoute({ + beforeLoad: async ({ matches }) => { + beforeLoadRuns++ + if (beforeLoadRuns > 1) { + observedContexts.push(matches[0]?.context) + reloadStarted.resolve() + await reload + } + return { locale: 'en' } + }, + component: () => { + const context = rootRoute.useRouteContext() + return
{context.value.locale ?? 'missing'}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByTestId('locale')).toHaveTextContent('en') + + let invalidation: Promise | undefined + try { + invalidation = router.invalidate() + await reloadStarted + await Vue.nextTick() + + expect(beforeLoadRuns).toBe(2) + expect(screen.getByTestId('locale')).toHaveTextContent('en') + + reload.resolve() + await invalidation + + expect(observedContexts).toEqual([{ locale: 'en' }]) + } finally { + reload.resolve() + await Promise.allSettled(invalidation ? [invalidation] : []) + } +}) + +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 { + 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 recordHydrationMessage = (...args: Array) => { + const message = args.map(String).join(' ') + if (/hydration|mismatch/i.test(message)) { + hydrationMessages.push(message) + } + } + vi.spyOn(console, 'error').mockImplementation(recordHydrationMessage) + vi.spyOn(console, 'warn').mockImplementation(recordHydrationMessage) + 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 ( + <> + {matchId.value} + + location: {location.value.state.issue8115Revision}; inherited:{' '} + {context.value.inheritedRevision}; self:{' '} + {context.value.selfRevision} + + + + ) + }, + }) + 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') +}) From 1f760bd2952274aa2b63f8c1253064b034a112a8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 16:02:09 +0200 Subject: [PATCH 04/11] fix(vue-router): react to provider context changes --- packages/vue-router/src/RouterProvider.tsx | 25 ++++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) 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 } } From a95edcb572e33afc1d6dedff1938e1a1561cc014 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 16:53:37 +0200 Subject: [PATCH 05/11] fix(router-core): refine context reconstruction --- .../tests/issue-8115-context.test.tsx | 55 ++++++++++--------- packages/router-core/src/load-client.ts | 27 ++++----- .../tests/public-hydration-contract.test.ts | 6 +- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/packages/react-router/tests/issue-8115-context.test.tsx b/packages/react-router/tests/issue-8115-context.test.tsx index 72e54c3e35..f60e53279e 100644 --- a/packages/react-router/tests/issue-8115-context.test.tsx +++ b/packages/react-router/tests/issue-8115-context.test.tsx @@ -710,33 +710,34 @@ test('a same-id navigation merges new inherited context with cached route contex 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 ( - <> - {matchId} - - location: {location.state.issue8115Revision}; inherited:{' '} - {context.inheritedRevision}; self: {context.selfRevision} - - - - ) - }, - }) + component: IndexComponent, + }) + function IndexComponent() { + const location = useLocation() + const context = indexRoute.useRouteContext() + const matchId = indexRoute.useMatch({ select: (match) => match.id }) + const navigate = indexRoute.useNavigate() + + return ( + <> + {matchId} + + location: {location.state.issue8115Revision}; inherited:{' '} + {context.inheritedRevision}; self: {context.selfRevision} + + + + ) + } const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]), history, diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index ca624da62f..093cae2985 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -377,22 +377,22 @@ async function contextualize( matches, routeId: route.id, } - let context = parentContext + let context: typeof parentContext try { - let routeContext = match._ctx - if (!routeContext && route.options.context) { - routeContext = match._ctx = - route.options.context({ - ...common, - deps: match.loaderDeps, - context: parentContext, - } satisfies RouteContextOptions) || {} - } - context = { + // Reuse the route's cached contribution while rebuilding its inheritance. + const routeContext = + match._ctx || + (route.options.context && + (match._ctx = + route.options.context({ + ...common, + deps: match.loaderDeps, + context: parentContext, + } satisfies RouteContextOptions) || {})) + match.context = context = { ...parentContext, ...routeContext, } - match.context = context } catch (cause) { match.context = parentContext releaseFlight(router, match) @@ -2240,7 +2240,6 @@ export async function hydrate(router: AnyRouter): Promise { let pendingBoundary: number | undefined let verifiedAssetEnd = 0 const retryFrom = (index: number) => { - pendingBoundary = Math.min(pendingBoundary ?? index, index) // The failing route's identity is still verified, but no descendant is. verifiedAssetEnd = Math.min(verifiedAssetEnd, index + 1) const removed = committed.splice(index) @@ -2420,6 +2419,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() From f26f71f7c54bf83363f0a051754f782f936f6a1e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 17:20:02 +0200 Subject: [PATCH 06/11] bytes --- packages/router-core/src/load-client.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 093cae2985..e1229d8d22 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -380,15 +380,13 @@ async function contextualize( let context: typeof parentContext try { // Reuse the route's cached contribution while rebuilding its inheritance. - const routeContext = - match._ctx || - (route.options.context && - (match._ctx = - route.options.context({ - ...common, - deps: match.loaderDeps, - context: parentContext, - } satisfies RouteContextOptions) || {})) + const routeContext = (match._ctx ||= route.options.context + ? route.options.context({ + ...common, + deps: match.loaderDeps, + context: parentContext, + } satisfies RouteContextOptions) || {} + : undefined) match.context = context = { ...parentContext, ...routeContext, From 7805d7ec81d2b2ee365ba616f48be338e63dbd07 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 17:24:28 +0200 Subject: [PATCH 07/11] type fixes --- packages/react-router/tests/issue-8115-context.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router/tests/issue-8115-context.test.tsx b/packages/react-router/tests/issue-8115-context.test.tsx index f60e53279e..7ecb553a48 100644 --- a/packages/react-router/tests/issue-8115-context.test.tsx +++ b/packages/react-router/tests/issue-8115-context.test.tsx @@ -588,7 +588,7 @@ test('#8115: hydration does not render a successful route with missing context w expect(recoverableHydrationErrors).toHaveLength(1) } finally { if (root) { - await act(() => root.unmount()) + await act(() => root!.unmount()) } container.remove() } @@ -826,7 +826,7 @@ test('a same-id search navigation merges fresh inherited context with cached rou path: '/child', loaderDeps: () => ({}), context: ({ context, location }) => ({ - cachedSelfRevision: `${context.inheritedRevision}:${String(location.search.revision)}`, + cachedSelfRevision: `${context.inheritedRevision}:${String((location.search as any).revision)}`, }), component: () => { const context = childRoute.useRouteContext() From e82f5354627e9b0aa3140e5dae26faf9771e428b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 19:23:07 +0200 Subject: [PATCH 08/11] context + immediate UI on load --- e2e/react-router/issue-4759/src/main.tsx | 46 ++++++++++++++----- .../issue-4759/tests/issue-4759.spec.ts | 11 +++-- packages/react-router/src/Transitioner.tsx | 2 +- packages/router-core/src/load-client.ts | 36 ++++++++------- 4 files changed, 61 insertions(+), 34 deletions(-) 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/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index e1229d8d22..9bedf658b7 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1991,26 +1991,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] @@ -2037,6 +2037,7 @@ export async function loadClientRoute( if (router._tx !== tx) { transferMatchResources(router, tx[3 /* matches */]) tx[3 /* matches */] = [] + settle?.() await awaitCurrent(router, tx) return } @@ -2044,8 +2045,7 @@ export async function loadClientRoute( router.stores.status.set('pending') router.stores.location.set(location) }) - // An unresolved cold root has no UI to retain. Child boundaries wait for - // contextualization to publish through onReady; provisional not-found waits + // An unresolved cold root has no UI to retain. Provisional not-found waits // for lazy routes to place the final boundary. if ( resolvedPrefix || @@ -2055,7 +2055,9 @@ export async function loadClientRoute( ) { 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) } From ff9d979317f298856c346e1218bb54e37e06b33e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 20:23:37 +0200 Subject: [PATCH 09/11] coderabbit is happy --- .../tests/issue-8115-context.test.tsx | 44 ++++++--- .../tests/issue-8115-context.test.tsx | 98 ++++++++++--------- .../tests/issue-8115-context.test.tsx | 19 +++- 3 files changed, 99 insertions(+), 62 deletions(-) diff --git a/packages/react-router/tests/issue-8115-context.test.tsx b/packages/react-router/tests/issue-8115-context.test.tsx index 7ecb553a48..cc6ce31210 100644 --- a/packages/react-router/tests/issue-8115-context.test.tsx +++ b/packages/react-router/tests/issue-8115-context.test.tsx @@ -37,7 +37,7 @@ afterEach(() => { cleanup() vi.restoreAllMocks() delete window.$_TSR - delete (window as any).$R + delete (window as Window & { $R?: unknown }).$R document.body.innerHTML = '' }) @@ -302,19 +302,26 @@ test('a same-id reload keeps the committed beforeLoad context visible until the render() expect(await screen.findByTestId('locale')).toHaveTextContent('en') - let invalidation!: Promise - await act(async () => { - invalidation = router.invalidate() - await reloadStarted - }) + let invalidation: Promise | undefined + try { + await act(async () => { + invalidation = router.invalidate() + await reloadStarted + }) - expect(beforeLoadRuns).toBe(2) - expect(screen.getByTestId('locale')).toHaveTextContent('en') + expect(beforeLoadRuns).toBe(2) + expect(screen.getByTestId('locale')).toHaveTextContent('en') - reload.resolve() - await act(() => invalidation) + reload.resolve() + await act(() => invalidation!) - expect(observedContexts).toEqual([{ locale: 'en' }]) + expect(observedContexts).toEqual([{ locale: 'en' }]) + } finally { + reload.resolve() + await act(async () => { + await Promise.allSettled(invalidation ? [invalidation] : []) + }) + } }) test('navigation merges fresh parent context with cached child preload context', async () => { @@ -536,6 +543,7 @@ test('#8115: hydration does not render a successful route with missing context w 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 ?? '')() @@ -562,10 +570,18 @@ test('#8115: hydration does not render a successful route with missing context w 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 && - error.message.startsWith( - "Hydration failed because the server rendered HTML didn't match the client.", + messages.some((message) => + /hydration (?:failed|mismatch)|server rendered HTML.*client/i.test( + message, + ), ) ) { recoverableHydrationErrors.push(error) @@ -585,7 +601,7 @@ test('#8115: hydration does not render a successful route with missing context w expect(clientContextAttempts).toBeGreaterThan(0) expect(container.querySelector('[data-testid="route-success"]')).toBeNull() expect(clientSuccessRenderValues).not.toContain(undefined) - expect(recoverableHydrationErrors).toHaveLength(1) + expect(recoverableHydrationErrors.length).toBeGreaterThan(0) } finally { if (root) { await act(() => root!.unmount()) diff --git a/packages/solid-router/tests/issue-8115-context.test.tsx b/packages/solid-router/tests/issue-8115-context.test.tsx index 72d4c4ff5f..efce66c4e1 100644 --- a/packages/solid-router/tests/issue-8115-context.test.tsx +++ b/packages/solid-router/tests/issue-8115-context.test.tsx @@ -8,6 +8,7 @@ import { } 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, @@ -20,7 +21,6 @@ import { createRouter, useLocation, } from '../src' -import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' declare module '@tanstack/history' { interface HistoryState { @@ -32,7 +32,7 @@ afterEach(() => { cleanup() vi.restoreAllMocks() delete window.$_TSR - delete (window as any).$R + delete (window as Window & { $R?: unknown }).$R document.body.innerHTML = '' }) @@ -96,9 +96,13 @@ test('invalidate merges fresh parent beforeLoad context with cached child contex 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') + 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 () => { @@ -213,13 +217,15 @@ test('a same-match reload merges new provider context with cached route context' await Promise.resolve() await router.invalidate() - expect(screen.getByTestId('full-context')).toHaveTextContent( - JSON.stringify({ - providerValue: 'B', - collision: 'route-cached:A', - derivedFromProvider: 'derived:A', - }), - ) + await waitFor(() => { + expect(screen.getByTestId('full-context')).toHaveTextContent( + JSON.stringify({ + providerValue: 'B', + collision: 'route-cached:A', + derivedFromProvider: 'derived:A', + }), + ) + }) }) test('a child context error preserves inherited context without the child contribution', async () => { @@ -458,9 +464,13 @@ test('a cached child context contribution is merged with fresh parent context', search: { version: 2 }, }) - expect( - screen.getByText('Parent: version-2; cached child: derived-from-version-1'), - ).toBeInTheDocument() + 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, @@ -526,32 +536,26 @@ test('#8115: hydration does not render a successful route with missing context w history: createMemoryHistory({ initialEntries: ['/'] }), isServer: true, }) - await serverRouter.load() - expect(serverRouter.state.matches.at(-1)?.context).toMatchObject({ - locale: 'en', - }) - expect(serverRouter.state.matches.at(-1)?.status).toBe('success') - - window.$_TSR = { - router: { - manifest: undefined, - matches: serverRouter.state.matches.map((match) => ({ - i: match.id - .replaceAll('~', '~~') - .replaceAll('\0', '~0') - .replaceAll('\uFFFD', '~r') - .replaceAll('/', '\0'), - s: match.status, - ssr: true, - u: match.updatedAt, - })), - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - } as TsrSsrGlobal + 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({ @@ -853,8 +857,12 @@ test('a same-id search navigation merges fresh inherited context with cached rou 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') + 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/tests/issue-8115-context.test.tsx b/packages/vue-router/tests/issue-8115-context.test.tsx index fe1833f964..d08eb85a64 100644 --- a/packages/vue-router/tests/issue-8115-context.test.tsx +++ b/packages/vue-router/tests/issue-8115-context.test.tsx @@ -33,7 +33,7 @@ afterEach(() => { cleanup() vi.restoreAllMocks() delete window.$_TSR - delete (window as any).$R + delete (window as Window & { $R?: unknown }).$R document.body.innerHTML = '' }) @@ -571,6 +571,7 @@ test('#8115: hydration does not render a successful route with missing context w 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 ?? '')() @@ -593,14 +594,26 @@ test('#8115: hydration does not render a successful route with missing context w 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(recordHydrationMessage) - vi.spyOn(console, 'warn').mockImplementation(recordHydrationMessage) + 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 { From c5a48eaf439ea76e947db866363b01f3fb76e0a6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 22:57:29 +0200 Subject: [PATCH 10/11] no context in pending/error/notFound components --- .../tests/issue-8115-context.test.tsx | 104 ------------------ packages/router-core/src/load-client.ts | 47 +++----- .../tests/issue-8115-context.test.tsx | 100 ----------------- .../tests/issue-8115-context.test.tsx | 103 ----------------- 4 files changed, 13 insertions(+), 341 deletions(-) diff --git a/packages/react-router/tests/issue-8115-context.test.tsx b/packages/react-router/tests/issue-8115-context.test.tsx index cc6ce31210..69399d26c0 100644 --- a/packages/react-router/tests/issue-8115-context.test.tsx +++ b/packages/react-router/tests/issue-8115-context.test.tsx @@ -220,110 +220,6 @@ test('a same-match reload merges new provider context with cached route context' ) }) -test('a child context error preserves inherited context without the child contribution', async () => { - const contextError = new Error('child context failed') - const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ - context: () => ({ rootValue: 'root' }), - component: Outlet, - }) - const parentRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/parent', - context: () => ({ parentValue: 'parent' }), - component: Outlet, - }) - const childRoute = createRoute({ - getParentRoute: () => parentRoute, - path: '/child', - context: (): { childValue: string } => { - throw contextError - }, - errorComponent: ({ error }) => { - const context = childRoute.useRouteContext() - - return ( -
-
- {error === contextError ? contextError.message : 'unexpected error'} -
-
{context.routerValue}
-
{context.rootValue}
-
{context.parentValue}
-
- {'childValue' in context ? context.childValue : 'absent'} -
-
- ) - }, - }) - const router = createRouter({ - routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), - history: createMemoryHistory({ initialEntries: ['/parent/child'] }), - context: { routerValue: 'router' }, - }) - - render() - - expect(await screen.findByTestId('route-error')).toHaveTextContent( - contextError.message, - ) - expect(screen.getByTestId('router-context')).toHaveTextContent('router') - expect(screen.getByTestId('root-context')).toHaveTextContent('root') - expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') - expect(screen.getByTestId('child-context')).toHaveTextContent('absent') -}) - -test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { - const reload = createControlledPromise() - const reloadStarted = createControlledPromise() - const observedContexts: Array = [] - let beforeLoadRuns = 0 - - const rootRoute = createRootRoute({ - beforeLoad: async ({ matches }) => { - beforeLoadRuns++ - if (beforeLoadRuns > 1) { - observedContexts.push(matches[0]?.context) - reloadStarted.resolve() - await reload - } - return { locale: 'en' } - }, - component: () => { - const { locale } = rootRoute.useRouteContext() - return
{locale ?? 'missing'}
- }, - }) - const router = createRouter({ - routeTree: rootRoute, - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - render() - expect(await screen.findByTestId('locale')).toHaveTextContent('en') - - let invalidation: Promise | undefined - try { - await act(async () => { - invalidation = router.invalidate() - await reloadStarted - }) - - expect(beforeLoadRuns).toBe(2) - expect(screen.getByTestId('locale')).toHaveTextContent('en') - - reload.resolve() - await act(() => invalidation!) - - expect(observedContexts).toEqual([{ locale: 'en' }]) - } finally { - reload.resolve() - await act(async () => { - await Promise.allSettled(invalidation ? [invalidation] : []) - }) - } -}) - test('navigation merges fresh parent context with cached child preload context', async () => { let parentBeforeLoadRuns = 0 let childContextRuns = 0 diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 9bedf658b7..b715a43954 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -12,7 +12,6 @@ import type { AnyRouteMatch } from './Matches' import type { NotFoundError } from './not-found' import type { AnyRoute, - BeforeLoadContextOptions, LoaderFnContext, RouteContextOptions, RouteLoaderFn, @@ -377,7 +376,6 @@ async function contextualize( matches, routeId: route.id, } - let context: typeof parentContext try { // Reuse the route's cached contribution while rebuilding its inheritance. const routeContext = (match._ctx ||= route.options.context @@ -387,12 +385,11 @@ async function contextualize( context: parentContext, } satisfies RouteContextOptions) || {} : undefined) - match.context = context = { + match.context = { ...parentContext, ...routeContext, } } catch (cause) { - match.context = parentContext releaseFlight(router, match) return [index, normalizeLaneError(router, lane, route, cause, options)] } @@ -412,29 +409,6 @@ async function contextualize( continue } - const base = options[2 /* base */][index] - // Keep the committed context observable until beforeLoad settles. - if (base?.id === match.id) { - match.context = base.context - } - - 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' @@ -442,7 +416,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] } @@ -457,18 +439,15 @@ async function contextualize( releaseFlight(router, match) return [index, outcome] } - context = { - ...context, + match.context = { + ...match.context, ...result, } } catch (cause) { releaseFlight(router, match) return [index, normalizeLaneError(router, lane, route, cause, options)] } finally { - match.context = context - if (match.status === 'pending') { - match.status = previousStatus - } + match.status = previousStatus setFetching(router, match, false, options[0 /* controller */]) } } diff --git a/packages/solid-router/tests/issue-8115-context.test.tsx b/packages/solid-router/tests/issue-8115-context.test.tsx index efce66c4e1..659cb59309 100644 --- a/packages/solid-router/tests/issue-8115-context.test.tsx +++ b/packages/solid-router/tests/issue-8115-context.test.tsx @@ -228,106 +228,6 @@ test('a same-match reload merges new provider context with cached route context' }) }) -test('a child context error preserves inherited context without the child contribution', async () => { - const contextError = new Error('child context failed') - const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ - context: () => ({ rootValue: 'root' }), - component: Outlet, - }) - const parentRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/parent', - context: () => ({ parentValue: 'parent' }), - component: Outlet, - }) - const childRoute = createRoute({ - getParentRoute: () => parentRoute, - path: '/child', - context: (): { childValue: string } => { - throw contextError - }, - errorComponent: ({ error }) => { - const context = childRoute.useRouteContext() - - return ( -
-
- {error === contextError ? contextError.message : 'unexpected error'} -
-
{context().routerValue}
-
{context().rootValue}
-
{context().parentValue}
-
- {'childValue' in context() ? context().childValue : 'absent'} -
-
- ) - }, - }) - const router = createRouter({ - routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), - history: createMemoryHistory({ initialEntries: ['/parent/child'] }), - context: { routerValue: 'router' }, - }) - - render(() => ) - - expect(await screen.findByTestId('route-error')).toHaveTextContent( - contextError.message, - ) - expect(screen.getByTestId('router-context')).toHaveTextContent('router') - expect(screen.getByTestId('root-context')).toHaveTextContent('root') - expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') - expect(screen.getByTestId('child-context')).toHaveTextContent('absent') -}) - -test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { - const reload = createControlledPromise() - const reloadStarted = createControlledPromise() - const observedContexts: Array = [] - let beforeLoadRuns = 0 - - const rootRoute = createRootRoute({ - beforeLoad: async ({ matches }) => { - beforeLoadRuns++ - if (beforeLoadRuns > 1) { - observedContexts.push(matches[0]?.context) - reloadStarted.resolve() - await reload - } - return { locale: 'en' } - }, - component: () => { - const context = rootRoute.useRouteContext() - return
{context().locale ?? 'missing'}
- }, - }) - const router = createRouter({ - routeTree: rootRoute, - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - render(() => ) - expect(await screen.findByTestId('locale')).toHaveTextContent('en') - - let invalidation!: Promise - try { - invalidation = router.invalidate() - await reloadStarted - - expect(beforeLoadRuns).toBe(2) - expect(screen.getByTestId('locale')).toHaveTextContent('en') - - reload.resolve() - await invalidation - - expect(observedContexts).toEqual([{ locale: 'en' }]) - } finally { - reload.resolve() - await Promise.allSettled(invalidation ? [invalidation] : []) - } -}) - test('navigation merges fresh parent context with cached child preload context', async () => { let parentBeforeLoadRuns = 0 let childContextRuns = 0 diff --git a/packages/vue-router/tests/issue-8115-context.test.tsx b/packages/vue-router/tests/issue-8115-context.test.tsx index d08eb85a64..6ba16737a7 100644 --- a/packages/vue-router/tests/issue-8115-context.test.tsx +++ b/packages/vue-router/tests/issue-8115-context.test.tsx @@ -232,109 +232,6 @@ test('a same-match reload merges new provider context with cached route context' ) }) -test('a child context error preserves inherited context without the child contribution', async () => { - const contextError = new Error('child context failed') - const rootRoute = createRootRouteWithContext<{ routerValue: string }>()({ - context: () => ({ rootValue: 'root' }), - component: Outlet, - }) - const parentRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/parent', - context: () => ({ parentValue: 'parent' }), - component: Outlet, - }) - const childRoute = createRoute({ - getParentRoute: () => parentRoute, - path: '/child', - context: (): { childValue: string } => { - throw contextError - }, - errorComponent: ({ error }) => { - const context = childRoute.useRouteContext() - - return ( -
-
- {error === contextError ? contextError.message : 'unexpected error'} -
-
{context.value.routerValue}
-
{context.value.rootValue}
-
{context.value.parentValue}
-
- {'childValue' in context.value - ? context.value.childValue - : 'absent'} -
-
- ) - }, - }) - const router = createRouter({ - routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), - history: createMemoryHistory({ initialEntries: ['/parent/child'] }), - context: { routerValue: 'router' }, - }) - - render() - - expect(await screen.findByTestId('route-error')).toHaveTextContent( - contextError.message, - ) - expect(screen.getByTestId('router-context')).toHaveTextContent('router') - expect(screen.getByTestId('root-context')).toHaveTextContent('root') - expect(screen.getByTestId('parent-context')).toHaveTextContent('parent') - expect(screen.getByTestId('child-context')).toHaveTextContent('absent') -}) - -test('a same-id reload keeps the committed beforeLoad context visible until the next result', async () => { - const reload = createControlledPromise() - const reloadStarted = createControlledPromise() - const observedContexts: Array = [] - let beforeLoadRuns = 0 - - const rootRoute = createRootRoute({ - beforeLoad: async ({ matches }) => { - beforeLoadRuns++ - if (beforeLoadRuns > 1) { - observedContexts.push(matches[0]?.context) - reloadStarted.resolve() - await reload - } - return { locale: 'en' } - }, - component: () => { - const context = rootRoute.useRouteContext() - return
{context.value.locale ?? 'missing'}
- }, - }) - const router = createRouter({ - routeTree: rootRoute, - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - render() - expect(await screen.findByTestId('locale')).toHaveTextContent('en') - - let invalidation: Promise | undefined - try { - invalidation = router.invalidate() - await reloadStarted - await Vue.nextTick() - - expect(beforeLoadRuns).toBe(2) - expect(screen.getByTestId('locale')).toHaveTextContent('en') - - reload.resolve() - await invalidation - - expect(observedContexts).toEqual([{ locale: 'en' }]) - } finally { - reload.resolve() - await Promise.allSettled(invalidation ? [invalidation] : []) - } -}) - test('navigation merges fresh parent context with cached child preload context', async () => { let parentBeforeLoadRuns = 0 let childContextRuns = 0 From f03f4e8c45f1429e4c01b1b96dc9dfb07e08d6fb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 20 Aug 2026 23:08:01 +0200 Subject: [PATCH 11/11] changesets --- .changeset/fine-doors-stop.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/fine-doors-stop.md 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