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