diff --git a/packages/react-router/tests/ClientOnly.test.tsx b/packages/react-router/tests/ClientOnly.test.tsx index 4bfe863d0b..d65bd1d6d1 100644 --- a/packages/react-router/tests/ClientOnly.test.tsx +++ b/packages/react-router/tests/ClientOnly.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import React from 'react' import ReactDOMServer from 'react-dom/server' import { act, cleanup, render, screen } from '@testing-library/react' @@ -12,7 +12,6 @@ import { import { ClientOnly } from '../src/ClientOnly' afterEach(() => { - vi.resetAllMocks() cleanup() }) @@ -70,7 +69,10 @@ describe('ClientOnly', () => { await router.load() // Mock useSyncExternalStore to simulate hydration - vi.spyOn(React, 'useSyncExternalStore').mockImplementation(() => true) + const useSyncExternalStore = vi + .spyOn(React, 'useSyncExternalStore') + .mockImplementation(() => true) + onTestFinished(() => useSyncExternalStore.mockRestore()) render() @@ -83,7 +85,10 @@ describe('ClientOnly', () => { await router.load() // Simulate hydration - vi.spyOn(React, 'useSyncExternalStore').mockImplementation(() => true) + const useSyncExternalStore = vi + .spyOn(React, 'useSyncExternalStore') + .mockImplementation(() => true) + onTestFinished(() => useSyncExternalStore.mockRestore()) // Re-render after hydration render() diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index 33153a4e11..4662348135 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test } from 'vitest' import { act, cleanup, @@ -267,6 +267,7 @@ test('legacy notFoundRoute drops a stale parent layout after navigation', async }) const rendered = render() + onTestFinished(() => rendered.unmount()) expect(await rendered.findByText('Parent layout')).toBeInTheDocument() expect(await rendered.findByText('Legacy not found')).toBeInTheDocument() expect(legacyLoads).toBe(1) @@ -278,7 +279,6 @@ test('legacy notFoundRoute drops a stale parent layout after navigation', async expect(rendered.queryByText('Parent layout')).not.toBeInTheDocument() expect(await rendered.findByText('Legacy not found')).toBeInTheDocument() expect(legacyLoads).toBe(1) - rendered.unmount() }) describe('matching on different param types', () => { diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 893cef01bc..967bb1db16 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { act, cleanup, @@ -48,17 +48,14 @@ const createTestManifest = ( }, }) satisfies Manifest -const browserHistories: Array> = [] - const createTestBrowserHistory = () => { const history = createBrowserHistory() - browserHistories.push(history) + onTestFinished(() => history.destroy()) return history } afterEach(() => { cleanup() - browserHistories.splice(0).forEach((history) => history.destroy()) window.history.replaceState(null, 'root', '/') delete window.$_TSR }) diff --git a/packages/react-router/tests/component-preload-retry-pending-min.test.tsx b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx index dcdd94af67..70d29eb166 100644 --- a/packages/react-router/tests/component-preload-retry-pending-min.test.tsx +++ b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { act } from 'react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { createControlledPromise } from '@tanstack/router-core' import { @@ -14,8 +14,6 @@ import { import type { ErrorComponentProps } from '../src' afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() cleanup() }) @@ -52,7 +50,11 @@ test('delayed component preload reveals pending UI', async () => { * ready and pendingMinMs has elapsed. */ test('component preload retry remains pending through pendingMinMs', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + vi.useRealTimers() + consoleError.mockRestore() + }) const retryChunk = createControlledPromise() let preloadAttempt = 0 diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx index ff33ec4bea..aa0de30e49 100644 --- a/packages/react-router/tests/component-preload-retry.test.tsx +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { createControlledPromise } from '@tanstack/router-core' import { @@ -15,13 +15,13 @@ import type { ErrorComponentProps } from '../src' afterEach(() => { cleanup() - vi.restoreAllMocks() - vi.unstubAllGlobals() - sessionStorage.clear() }) test('a successful server component download is reused', async () => { vi.stubGlobal('window', undefined) + onTestFinished(() => { + vi.unstubAllGlobals() + }) const importer = vi.fn().mockResolvedValue({ default: () => null }) const Page = lazyRouteComponent(importer) @@ -46,7 +46,8 @@ test('concurrent component preloads share the import', async () => { }) test('a failed component download is retried from the route error UI', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) const PageContent = () =>
Page content
const importer = vi @@ -97,6 +98,7 @@ test('a failed component download is retried from the route error UI', async () }) test('renders after retrying a module download that failed during preload', async () => { + onTestFinished(() => sessionStorage.clear()) const PageContent = () =>
Page content
const importer = vi .fn<() => Promise<{ default: typeof PageContent }>>() diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index b5ee4b6709..bba205dc27 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + onTestFinished, + test, + vi, +} from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { @@ -48,7 +56,6 @@ beforeEach(() => { afterEach(() => { history.destroy() - vi.resetAllMocks() window.history.replaceState(null, 'root', '/') cleanup() }) @@ -296,8 +303,12 @@ test('global catch boundary resets when a background child generation recovers', routeTree: rootRoute.addChildren([childRoute]), history, }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + consoleWarn.mockRestore() + consoleError.mockRestore() + }) render() expect( @@ -345,8 +356,12 @@ test('ancestor route errorComponent resets when a background child generation re routeTree: rootRoute.addChildren([childRoute]), history, }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + consoleWarn.mockRestore() + consoleError.mockRestore() + }) let invalidation: Promise | undefined try { diff --git a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx index 57d99c8af5..e70701284a 100644 --- a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx +++ b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { act } from '@testing-library/react' import { hydrateRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' import { hydrate } from '../src/ssr/client' @@ -22,17 +22,6 @@ declare global { } } -const testCleanups: Array<() => void | Promise> = [] - -afterEach(async () => { - while (testCleanups.length) { - await testCleanups.pop()!() - } - vi.restoreAllMocks() - window.$_TSR = undefined - document.body.innerHTML = '' -}) - describe('hydrating a server-capped boundary lane', () => { test('recovers a /404 payload against a missing browser URL', async () => { function MissingPage() { @@ -87,6 +76,9 @@ describe('hydrating a server-capped boundary lane', () => { buffer: [], initialized: false, } + onTestFinished(() => { + window.$_TSR = undefined + }) await hydrate(clientRouter) @@ -98,8 +90,9 @@ describe('hydrating a server-capped boundary lane', () => { root = hydrateRoot(container, , { onRecoverableError: () => {}, }) - testCleanups.push(async () => { + onTestFinished(async () => { await act(() => root.unmount()) + container.remove() }) await Promise.resolve() }) @@ -229,6 +222,9 @@ describe('hydrating a server-capped boundary lane', () => { buffer: [], initialized: false, } + onTestFinished(() => { + window.$_TSR = undefined + }) await hydrate(clientRouter) @@ -238,11 +234,13 @@ describe('hydrating a server-capped boundary lane', () => { const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) let root!: ReturnType await act(async () => { root = hydrateRoot(container, ) - testCleanups.push(async () => { + onTestFinished(async () => { await act(() => root.unmount()) + container.remove() }) await Promise.resolve() }) diff --git a/packages/react-router/tests/hydration-terminal-lane.test.tsx b/packages/react-router/tests/hydration-terminal-lane.test.tsx index 35784ea916..56463dac80 100644 --- a/packages/react-router/tests/hydration-terminal-lane.test.tsx +++ b/packages/react-router/tests/hydration-terminal-lane.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { hydrate } from '@tanstack/router-core/ssr/client' import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' import { @@ -44,11 +44,13 @@ function bootstrap( afterEach(() => { cleanup() - delete window.$_TSR }) describe('hydration terminal lane', () => { test('keeps server data while loading only the missing client suffix', async () => { + onTestFinished(() => { + delete window.$_TSR + }) const parentLoader = vi.fn(() => 'client-parent') const childLoader = vi.fn(() => 'client-child') const rootRoute = createRootRoute({ component: Outlet }) diff --git a/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx index 56aed7b375..45adeb46be 100644 --- a/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx +++ b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx @@ -6,7 +6,7 @@ import { screen, waitFor, } from '@testing-library/react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { HeadContent, Link, @@ -20,11 +20,13 @@ import { afterEach(() => { cleanup() - document.head.innerHTML = '' }) // https://github.com/TanStack/router/issues/7635 test('#7635: a parent beforeLoad error replaces the previous child title', async () => { + onTestFinished(() => { + document.head.innerHTML = '' + }) const appError = new Error('App beforeLoad failed') const appErrorRendered = vi.fn() const childHead = vi.fn(() => ({ diff --git a/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx index 9f1c0eec3f..5c091d8279 100644 --- a/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx +++ b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { act, cleanup, @@ -20,10 +20,6 @@ import { } from '../src' import type { ErrorComponentProps } from '../src' -afterEach(() => { - cleanup() -}) - // https://github.com/TanStack/router/issues/7638 // router.invalidate() called inside React.startTransition while a nested // route is showing its errorComponent must complete the reload and land back @@ -112,6 +108,8 @@ function setup({ failVia }: { failVia: 'render' | 'loader' }) { test.each(['render', 'loader'] as const)( 'invalidate() inside startTransition through a nested %s-error route does not crash', async (failVia) => { + onTestFinished(cleanup) + // Error boundaries log caught errors through console.error, and so does a // hooks-order crash. Capture instead of polluting the test output, then // inspect the captured calls for the crash signature. @@ -125,54 +123,7 @@ test.each(['render', 'loader'] as const)( } = setup({ failVia }) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - render() - - expect(await screen.findByTestId('error-ui')).toHaveTextContent( - `error: ${failVia} error`, - ) - const initialErrorRenders = getErrorRenders() - expect(childLoader).toHaveBeenCalledTimes(1) - consoleError.mockClear() - - fireEvent.click(screen.getByTestId('invalidate')) - - await waitFor(() => { - expect(childLoader).toHaveBeenCalledTimes(2) - expect(screen.getByTestId('invalidate')).toHaveTextContent('pending') - expect(screen.getByTestId('invalidate')).toBeDisabled() - }) - expect(secondChildLoad.status).toBe('pending') - - const invalidation = getInvalidation() - if (!invalidation) { - throw new Error('invalidate action did not return its promise') - } - - await act(async () => { - secondChildLoad.resolve() - await invalidation - }) - - await waitFor(() => { - expect(screen.getByTestId('error-ui')).toHaveTextContent( - `error: ${failVia} error`, - ) - expect(getErrorRenders()).toBeGreaterThan(initialErrorRenders) - expect(screen.getByTestId('invalidate')).toHaveTextContent('invalidate') - expect(screen.getByTestId('invalidate')).toBeEnabled() - }) - - fireEvent.click(screen.getByTestId('parent-action')) - expect(parentAction).toHaveBeenCalledTimes(1) - - const hooksCrash = consoleError.mock.calls.find((call) => - call.some((arg) => - String(arg?.message ?? arg).includes('Rendered more hooks'), - ), - ) - expect(hooksCrash).toBeUndefined() - } finally { + onTestFinished(async () => { if (secondChildLoad.status === 'pending') { await act(async () => { secondChildLoad.resolve() @@ -180,6 +131,53 @@ test.each(['render', 'loader'] as const)( }) } consoleError.mockRestore() + }) + + render() + + expect(await screen.findByTestId('error-ui')).toHaveTextContent( + `error: ${failVia} error`, + ) + const initialErrorRenders = getErrorRenders() + expect(childLoader).toHaveBeenCalledTimes(1) + consoleError.mockClear() + + fireEvent.click(screen.getByTestId('invalidate')) + + await waitFor(() => { + expect(childLoader).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('invalidate')).toHaveTextContent('pending') + expect(screen.getByTestId('invalidate')).toBeDisabled() + }) + expect(secondChildLoad.status).toBe('pending') + + const invalidation = getInvalidation() + if (!invalidation) { + throw new Error('invalidate action did not return its promise') } + + await act(async () => { + secondChildLoad.resolve() + await invalidation + }) + + await waitFor(() => { + expect(screen.getByTestId('error-ui')).toHaveTextContent( + `error: ${failVia} error`, + ) + expect(getErrorRenders()).toBeGreaterThan(initialErrorRenders) + expect(screen.getByTestId('invalidate')).toHaveTextContent('invalidate') + expect(screen.getByTestId('invalidate')).toBeEnabled() + }) + + fireEvent.click(screen.getByTestId('parent-action')) + expect(parentAction).toHaveBeenCalledTimes(1) + + const hooksCrash = consoleError.mock.calls.find((call) => + call.some((arg) => + String(arg?.message ?? arg).includes('Rendered more hooks'), + ), + ) + expect(hooksCrash).toBeUndefined() }, ) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index e9ff839d63..d2c5797824 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -1,5 +1,14 @@ import React from 'react' -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + test, + vi, +} from 'vitest' import { act, cleanup, @@ -2511,6 +2520,7 @@ describe('Link', () => { const homeLink = await screen.findByTestId('home-link') const consoleWarnSpy = vi.spyOn(console, 'warn') + onTestFinished(() => consoleWarnSpy.mockRestore()) await act(() => fireEvent.click(homeLink)) @@ -2519,8 +2529,6 @@ describe('Link', () => { expect(homeHeading).toBeInTheDocument() expect(consoleWarnSpy).not.toHaveBeenCalled() - - consoleWarnSpy.mockRestore() }) test('when navigating from /posts to ../posts/$postId', async () => { @@ -3071,6 +3079,7 @@ describe('Link', () => { expect(post1Heading).toBeInTheDocument() const consoleWarnSpy = vi.spyOn(console, 'warn') + onTestFinished(() => consoleWarnSpy.mockRestore()) const usersLink = await screen.findByTestId('users-link') await act(() => fireEvent.click(usersLink)) @@ -3088,8 +3097,6 @@ describe('Link', () => { expect(user1Heading).toBeInTheDocument() expect(consoleWarnSpy).not.toHaveBeenCalled() - - consoleWarnSpy.mockRestore() }) test('when navigating from /posts/$postId to ./info and the current route is /posts/$postId/details', async () => { @@ -3786,6 +3793,7 @@ describe('Link', () => { test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { const consoleWarnSpy = vi.spyOn(console, 'warn') + onTestFinished(() => consoleWarnSpy.mockRestore()) const rootRoute = createRootRoute() @@ -3934,8 +3942,6 @@ describe('Link', () => { expect(consoleWarnSpy).toHaveBeenCalledWith( 'Could not find match for from: /invoices', ) - - consoleWarnSpy.mockRestore() }) test('when navigating to /posts/$postId/info which is declaratively masked as /posts/$postId', async () => { diff --git a/packages/react-router/tests/on-rendered-same-href-state.test.tsx b/packages/react-router/tests/on-rendered-same-href-state.test.tsx index 76e7a0cb9d..397a9bf1ae 100644 --- a/packages/react-router/tests/on-rendered-same-href-state.test.tsx +++ b/packages/react-router/tests/on-rendered-same-href-state.test.tsx @@ -1,6 +1,6 @@ import { act } from 'react' import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { Outlet, @@ -10,14 +10,7 @@ import { createRouter, } from '../src' -const testCleanups: Array<() => void> = [] - -afterEach(() => { - while (testCleanups.length) { - testCleanups.pop()!() - } - cleanup() -}) +afterEach(cleanup) test('onRendered fires for a same-href navigation with a new history key', async () => { const rootRoute = createRootRoute({ component: () => }) @@ -42,7 +35,7 @@ test('onRendered fires for a same-href navigation with a new history key', async const onRendered = vi.fn() const unsubscribe = router.subscribe('onRendered', onRendered) - testCleanups.push(unsubscribe) + onTestFinished(unsubscribe) await act(() => router.navigate({ to: '/', diff --git a/packages/react-router/tests/preloaded-mount-resolution.test.tsx b/packages/react-router/tests/preloaded-mount-resolution.test.tsx index 1679546c79..f1c5d477c8 100644 --- a/packages/react-router/tests/preloaded-mount-resolution.test.tsx +++ b/packages/react-router/tests/preloaded-mount-resolution.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, onTestFinished, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import * as React from 'react' import { createRoot } from 'react-dom/client' import { createMemoryHistory } from '@tanstack/history' @@ -27,18 +27,16 @@ import { * benchmark (benchmarks/memory/client/scenarios/mount-unmount), which CI runs. */ -let prevActEnv: unknown - -beforeEach(() => { - prevActEnv = (globalThis as any).IS_REACT_ACT_ENVIRONMENT +const disableActEnvironment = () => { + const prevActEnv = (globalThis as any).IS_REACT_ACT_ENVIRONMENT ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false -}) - -afterEach(() => { - ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = prevActEnv -}) + onTestFinished(() => { + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = prevActEnv + }) +} test('mounting after a settled load still resolves status and fires onRendered', async () => { + disableActEnvironment() const lifecycle: Array<'layout' | 'rendered'> = [] const Home = () => { React.useLayoutEffect(() => { @@ -70,20 +68,14 @@ test('mounting after a settled load still resolves status and fires onRendered', }) const onResolved = vi.fn() const onLoad = vi.fn() - const unsubscribers = [ - router.subscribe('onRendered', onRendered), - router.subscribe('onResolved', onResolved), - router.subscribe('onLoad', onLoad), - ] - const unsubscribe = () => unsubscribers.forEach((fn) => fn()) + onTestFinished(router.subscribe('onRendered', onRendered)) + onTestFinished(router.subscribe('onResolved', onResolved)) + onTestFinished(router.subscribe('onLoad', onLoad)) const container = document.createElement('div') document.body.appendChild(container) const reactRoot = createRoot(container) - let renderedTimeout: ReturnType | undefined onTestFinished(() => { - clearTimeout(renderedTimeout) - unsubscribe() reactRoot.unmount() container.remove() }) @@ -101,9 +93,10 @@ test('mounting after a settled load still resolves status and fires onRendered', await Promise.race([ rendered, new Promise((_, reject) => { - renderedTimeout = setTimeout(() => { + const renderedTimeout = setTimeout(() => { reject(new Error('Timed out waiting for onRendered')) }, 2000) + onTestFinished(() => clearTimeout(renderedTimeout)) }), ]) @@ -115,6 +108,7 @@ test('mounting after a settled load still resolves status and fires onRendered', }) test('mounting during a load keeps the existing generation', async () => { + disableActEnvironment() const gate = createControlledPromise() const beforeLoad = vi.fn() const loader = vi.fn(() => gate) diff --git a/packages/react-router/tests/public-presentation-lane-contract.test.tsx b/packages/react-router/tests/public-presentation-lane-contract.test.tsx index 862fffc93f..589e09a86f 100644 --- a/packages/react-router/tests/public-presentation-lane-contract.test.tsx +++ b/packages/react-router/tests/public-presentation-lane-contract.test.tsx @@ -1,5 +1,5 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { Outlet, @@ -221,24 +221,21 @@ describe('public presentation lane contracts', () => { successor = router.navigate({ to: '/second' }) } }) + onTestFinished(unsubscribeResolved) const unsubscribeRendered = router.subscribe('onRendered', (event) => { if (event.toLocation.pathname !== '/') { renderedPaths.push(event.toLocation.pathname) } }) + onTestFinished(unsubscribeRendered) - try { - await act(() => router.navigate({ to: '/first' })) - await act(async () => { - await successor - }) + await act(() => router.navigate({ to: '/first' })) + await act(async () => { + await successor + }) - expect(screen.getByText('Second')).toBeInTheDocument() - expect(screen.queryByText('First')).not.toBeInTheDocument() - expect(renderedPaths).toEqual(['/second']) - } finally { - unsubscribeResolved() - unsubscribeRendered() - } + expect(screen.getByText('Second')).toBeInTheDocument() + expect(screen.queryByText('First')).not.toBeInTheDocument() + expect(renderedPaths).toEqual(['/second']) }) }) diff --git a/packages/react-router/tests/react-render-owner-contract.test.tsx b/packages/react-router/tests/react-render-owner-contract.test.tsx index 0797fa5264..8d08bf4bbc 100644 --- a/packages/react-router/tests/react-render-owner-contract.test.tsx +++ b/packages/react-router/tests/react-render-owner-contract.test.tsx @@ -1,6 +1,6 @@ import { act } from 'react' import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { RouterProvider, createControlledPromise, @@ -9,14 +9,7 @@ import { createRouter, } from '../src' -const testCleanups: Array<() => void> = [] - -afterEach(() => { - while (testCleanups.length) { - testCleanups.pop()!() - } - cleanup() -}) +afterEach(cleanup) test('a suspended same-membership publication cannot acknowledge its successor', async () => { const firstRenderStarted = createControlledPromise() @@ -55,7 +48,7 @@ test('a suspended same-membership publication cannot acknowledge its successor', Number((event.toLocation.search as Record).revision), ) }) - testCleanups.push(unsubscribe) + onTestFinished(unsubscribe) let firstNavigation!: Promise await act(async () => { diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index 0c254fb098..cf8d7ce393 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -8,7 +8,15 @@ import { screen, } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + onTestFinished, + test, + vi, +} from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { @@ -35,8 +43,6 @@ beforeEach(() => { afterEach(() => { history.destroy() - vi.clearAllMocks() - vi.resetAllMocks() window.history.replaceState(null, 'root', '/') cleanup() }) @@ -228,6 +234,7 @@ describe('redirect', () => { const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) const rootRoute = createRootRoute({ component: () => , diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index f42456b7be..8e6469d741 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -60,10 +60,10 @@ describe('renderRouterToStream - pipeable sync errors', () => { reactDomServerMocks.renderToReadableStream = vi.fn(() => stream) const router = await buildRouter() - const controller = new AbortController() onTestFinished(() => { router.serverSsr?.cleanup() }) + const controller = new AbortController() const response = unwrapResponse( await renderRouterToStream({ @@ -91,23 +91,21 @@ describe('renderRouterToStream - pipeable sync errors', () => { ) const router = await buildRouter() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/'), - router, - responseHeaders: new Headers(), - children: null, - }), - ) - - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'sync-react-error') - } finally { - errorSpy.mockRestore() + onTestFinished(() => { router.serverSsr?.cleanup() - } + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + + expect(abort).toHaveBeenCalledOnce() + await expectBodyRejects(response, 'sync-react-error') }) test('sync non-Error onError before pipeable assignment still errors body', async () => { @@ -120,23 +118,21 @@ describe('renderRouterToStream - pipeable sync errors', () => { ) const router = await buildRouter() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/'), - router, - responseHeaders: new Headers(), - children: null, - }), - ) - - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'string-react-error') - } finally { - errorSpy.mockRestore() + onTestFinished(() => { router.serverSsr?.cleanup() - } + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + + expect(abort).toHaveBeenCalledOnce() + await expectBodyRejects(response, 'string-react-error') }) test('sync undefined onError before pipeable assignment still errors body', async () => { @@ -149,23 +145,21 @@ describe('renderRouterToStream - pipeable sync errors', () => { ) const router = await buildRouter() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/'), - router, - responseHeaders: new Headers(), - children: null, - }), - ) - - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'SSR aborted') - } finally { - errorSpy.mockRestore() + onTestFinished(() => { router.serverSsr?.cleanup() - } + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + + expect(abort).toHaveBeenCalledOnce() + await expectBodyRejects(response, 'SSR aborted') }) test('undefined onError after response attach errors body', async () => { @@ -179,24 +173,22 @@ describe('renderRouterToStream - pipeable sync errors', () => { ) const router = await buildRouter() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/'), - router, - responseHeaders: new Headers(), - children: null, - }), - ) - - onError(undefined, { componentStack: '' }) - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'SSR aborted') - } finally { - errorSpy.mockRestore() + onTestFinished(() => { router.serverSsr?.cleanup() - } + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + + onError(undefined, { componentStack: '' }) + expect(abort).toHaveBeenCalledOnce() + await expectBodyRejects(response, 'SSR aborted') }) test('setup throw rejects instead of returning streamed 200', async () => { @@ -206,24 +198,22 @@ describe('renderRouterToStream - pipeable sync errors', () => { }) const router = await buildRouter() - const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') const originalServerSsr = router.serverSsr! - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - await expect( - renderRouterToStream({ - request: new Request('http://localhost/'), - router, - responseHeaders: new Headers(), - children: null, - }), - ).rejects.toThrow('setup-boom') - - expect(cleanup).toHaveBeenCalledOnce() - } finally { - errorSpy.mockRestore() + onTestFinished(() => { originalServerSsr.cleanup() - } + }) + const cleanup = vi.spyOn(originalServerSsr, 'cleanup') + vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect( + renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ).rejects.toThrow('setup-boom') + + expect(cleanup).toHaveBeenCalledOnce() }) test('request abort cancels pipeable rendering before the response body is consumed', async () => { @@ -236,29 +226,28 @@ describe('renderRouterToStream - pipeable sync errors', () => { ) const router = await buildRouter() + onTestFinished(() => { + router.serverSsr?.cleanup() + }) const controller = new AbortController() - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/', { - signal: controller.signal, - }), - router, - responseHeaders: new Headers(), - children: null, + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/', { + signal: controller.signal, }), - ) + router, + responseHeaders: new Headers(), + children: null, + }), + ) - expect(response.body).not.toBeNull() - controller.abort(new Error('request-gone')) - await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce()) - const terminated = await Promise.race([ - expectBodyRejects(response, 'request-gone').then(() => true), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - expect(terminated).toBe(true) - } finally { - router.serverSsr?.cleanup() - } + expect(response.body).not.toBeNull() + controller.abort(new Error('request-gone')) + await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce()) + const terminated = await Promise.race([ + expectBodyRejects(response, 'request-gone').then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 2000)), + ]) + expect(terminated).toBe(true) }) }) diff --git a/packages/react-router/tests/root-pending-min.test.tsx b/packages/react-router/tests/root-pending-min.test.tsx index 0216ae4fec..b3dfc20390 100644 --- a/packages/react-router/tests/root-pending-min.test.tsx +++ b/packages/react-router/tests/root-pending-min.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { act, cleanup, render, screen } from '@testing-library/react' import { hydrateRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' import { hydrate } from '../src/ssr/client' import { @@ -13,16 +13,7 @@ import { createRouter, } from '../src' -const testCleanups: Array<() => void | Promise> = [] - -afterEach(async () => { - while (testCleanups.length) { - await testCleanups.pop()!() - } - cleanup() - vi.useRealTimers() - delete window.$_TSR -}) +afterEach(cleanup) test('a post-hydration root reload keeps its fallback through pendingMinMs', async () => { const reloadGate = createControlledPromise() @@ -67,6 +58,9 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy buffer: [], initialized: false, } + onTestFinished(() => { + delete window.$_TSR + }) await hydrate(router) expect(router.ssr).toBeDefined() @@ -76,6 +70,9 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy expect(rootLoader).not.toHaveBeenCalled() vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) let invalidation!: Promise await act(async () => { @@ -141,12 +138,12 @@ test('root route hydration preserves component state across its Suspense boundar container.innerHTML = html document.body.appendChild(container) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) let root!: ReturnType await act(async () => { root = hydrateRoot(container, ) - testCleanups.push(async () => { + onTestFinished(async () => { await act(() => root.unmount()) - consoleError.mockRestore() container.remove() }) await Promise.resolve() diff --git a/packages/react-router/tests/router-client-stream-cleanup.test.tsx b/packages/react-router/tests/router-client-stream-cleanup.test.tsx index 865b7cd732..ef332be095 100644 --- a/packages/react-router/tests/router-client-stream-cleanup.test.tsx +++ b/packages/react-router/tests/router-client-stream-cleanup.test.tsx @@ -1,5 +1,5 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { Component } from 'react' import { createMemoryHistory } from '@tanstack/history' import { RouterClient } from '../src/ssr/RouterClient' @@ -27,11 +27,13 @@ class ErrorBoundary extends Component< afterEach(() => { cleanup() - delete window.$_TSR - hydrate.mockReset() }) test('RouterClient signals streaming cleanup without hiding a hydration failure', async () => { + onTestFinished(() => { + delete window.$_TSR + hydrate.mockReset() + }) const error = new Error('hydration failed') hydrate.mockRejectedValue(error) const rootRoute = createRootRoute({ component: () =>
Ready
}) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 43c4d47423..5c9a753637 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1,5 +1,13 @@ import { act, useEffect, useRef } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + vi, +} from 'vitest' import { cleanup, fireEvent, @@ -2683,6 +2691,9 @@ describe('notFound in beforeLoad with pendingComponent', () => { expect(await screen.findByTestId('home-page')).toBeInTheDocument() vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) let navigation!: Promise const pendingTestId = `${pendingOwner}-pending` try { @@ -2700,14 +2711,10 @@ describe('notFound in beforeLoad with pendingComponent', () => { expect(screen.getByTestId('parent-component')).toBeInTheDocument() } } finally { - try { - await act(async () => { - beforeLoad.resolve() - await navigation - }) - } finally { - vi.useRealTimers() - } + await act(async () => { + beforeLoad.resolve() + await navigation + }) } expect(screen.getByTestId('parent-not-found')).toHaveTextContent( diff --git a/packages/react-router/tests/transitioner-listener-errors.test.tsx b/packages/react-router/tests/transitioner-listener-errors.test.tsx index 9682b8f60c..95c8f6b17f 100644 --- a/packages/react-router/tests/transitioner-listener-errors.test.tsx +++ b/packages/react-router/tests/transitioner-listener-errors.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { Outlet, RouterProvider, @@ -10,14 +10,7 @@ import { createRouter, } from '../src' -const testCleanups: Array<() => void | Promise> = [] - -afterEach(async () => { - while (testCleanups.length) { - await testCleanups.pop()!() - } - cleanup() -}) +afterEach(cleanup) test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { const firstOnEnter = vi.fn() @@ -57,13 +50,14 @@ test('a throwing load-event listener cannot interrupt route hooks or later navig throw listenerError } }) + onTestFinished(unsubscribe) const unsubscribeLater = router.subscribe('onLoad', (event) => { if (event.toLocation.pathname !== '/') { loadedPaths.push(event.toLocation.pathname) laterOnLoad(event) } }) - testCleanups.push(unsubscribe, unsubscribeLater) + onTestFinished(unsubscribeLater) await act(() => router.navigate({ to: '/first' })) diff --git a/packages/react-router/tests/transitioner-remount.test.tsx b/packages/react-router/tests/transitioner-remount.test.tsx index 76cc6393ce..ffe2b8cbe4 100644 --- a/packages/react-router/tests/transitioner-remount.test.tsx +++ b/packages/react-router/tests/transitioner-remount.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/react' import { Outlet, @@ -39,6 +39,7 @@ describe('Transitioner remount', () => { it('does not load after the provider unmounts', async () => { const { history, router } = setup() const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) const first = render() expect(await screen.findByText('Index')).toBeInTheDocument() @@ -56,8 +57,6 @@ describe('Transitioner remount', () => { expect(router.history.location.pathname).toBe('/next') // ...but the router never processed it, so its committed state stayed put. expect(router.state.location.pathname).toBe('/') - - loadSpy.mockRestore() }) // Remounting the same router instance must re-establish the subscription so @@ -67,6 +66,7 @@ describe('Transitioner remount', () => { // Spy before the first mount so the subscription captures the spy by // reference - both the first and second mounts subscribe with it. const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) const first = render() expect(await screen.findByText('Index')).toBeInTheDocument() @@ -85,7 +85,5 @@ describe('Transitioner remount', () => { expect(await screen.findByText('Next')).toBeInTheDocument() expect(router.state.location.pathname).toBe('/next') await waitFor(() => expect(loadSpy).toHaveBeenCalledTimes(1)) - - loadSpy.mockRestore() }) }) diff --git a/packages/react-router/tests/transitioner-render-ack.test.tsx b/packages/react-router/tests/transitioner-render-ack.test.tsx index 74bbe7d485..6214244a44 100644 --- a/packages/react-router/tests/transitioner-render-ack.test.tsx +++ b/packages/react-router/tests/transitioner-render-ack.test.tsx @@ -1,6 +1,6 @@ import { StrictMode, act } from 'react' import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { Outlet, RouterProvider, @@ -11,19 +11,13 @@ import { createRouter, } from '../src' -const testCleanups: Array<() => void> = [] - -afterEach(() => { - while (testCleanups.length) { - testCleanups.pop()!() - } - cleanup() - vi.useRealTimers() - vi.unstubAllEnvs() -}) +afterEach(cleanup) test('a route lifecycle callback cannot strand a production navigation', async () => { vi.stubEnv('NODE_ENV', 'production') + onTestFinished(() => { + vi.unstubAllEnvs() + }) expect(process.env.NODE_ENV).toBe('production') const rootRoute = createRootRoute({ component: Outlet }) const indexRoute = createRoute({ @@ -58,7 +52,7 @@ test('a route lifecycle callback cannot strand a production navigation', async ( } } window.addEventListener('error', preventGlobalReport) - testCleanups.push(() => { + onTestFinished(() => { window.removeEventListener('error', preventGlobalReport) }) @@ -111,7 +105,7 @@ test('same-location invalidation resolves after its refreshed DOM commits', asyn const unsubscribe = router.subscribe('onResolved', () => { refreshedDomWasVisible.push(screen.queryByText('Generation 2') !== null) }) - testCleanups.push(unsubscribe) + onTestFinished(unsubscribe) await act(() => router.invalidate()) expect(screen.getByText('Generation 2')).toBeInTheDocument() @@ -181,6 +175,9 @@ test('a navigation started by route lifecycle keeps the pending minimum of its o expect(await screen.findByText('Index')).toBeInTheDocument() await waitFor(() => expect(router.state.status).toBe('idle')) vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) let firstNavigation!: Promise await act(async () => { @@ -237,19 +234,20 @@ test('StrictMode effect replay preserves renderer commit sequencing', async () = }) const eventLog: Array = [] - const unsubscribers = [ + onTestFinished( router.subscribe('onResolved', (event) => { if (event.toLocation.pathname === '/next') { eventLog.push('onResolved:/next') } }), + ) + onTestFinished( router.subscribe('onRendered', (event) => { if (event.toLocation.pathname === '/next') { eventLog.push('onRendered:/next') } }), - ] - testCleanups.push(...unsubscribers) + ) await act(() => router.navigate({ to: '/next' })) expect(eventLog).toEqual(['onResolved:/next', 'onRendered:/next']) diff --git a/packages/react-router/tests/useMatch.test.tsx b/packages/react-router/tests/useMatch.test.tsx index ac4c52f6cc..fd8c69ef12 100644 --- a/packages/react-router/tests/useMatch.test.tsx +++ b/packages/react-router/tests/useMatch.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -233,16 +233,13 @@ describe('useMatch', () => { }) } }) - try { - fireEvent.click(screen.getByText('Other')) - await waitFor(() => expect(returnNavigation).toBeDefined()) - await returnNavigation + onTestFinished(unsubscribe) + fireEvent.click(screen.getByText('Other')) + await waitFor(() => expect(returnNavigation).toBeDefined()) + await returnNavigation - expect(await screen.findByText('Item revision 2')).toBeInTheDocument() - expect(screen.queryByText('Other route')).not.toBeInTheDocument() - } finally { - unsubscribe() - } + expect(await screen.findByText('Item revision 2')).toBeInTheDocument() + expect(screen.queryByText('Other route')).not.toBeInTheDocument() }) describe('when match is not found', () => { diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 1ec1072a26..b6c412b914 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1,6 +1,14 @@ import React, { act } from 'react' import '@testing-library/jest-dom/vitest' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + onTestFinished, + test, + vi, +} from 'vitest' import { cleanup, configure, @@ -38,8 +46,6 @@ beforeEach(() => { afterEach(() => { history.destroy() window.history.replaceState(null, 'root', '/') - vi.clearAllMocks() - vi.resetAllMocks() cleanup() }) @@ -1054,6 +1060,7 @@ test('when navigating from /invoices to ./invoiceId and the current route is /po }) const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => consoleWarn.mockRestore()) render() @@ -1072,8 +1079,6 @@ test('when navigating from /invoices to ./invoiceId and the current route is /po expect(consoleWarn).toHaveBeenCalledWith( 'Could not find match for from: /invoices', ) - - consoleWarn.mockRestore() }) test('when navigating to /posts/$postId/info which is masked as /posts/$postId', async () => { @@ -1362,6 +1367,7 @@ test(' navigates only once in ', async () => { }) const navigateSpy = vi.spyOn(router, 'navigate') + onTestFinished(() => navigateSpy.mockRestore()) render() diff --git a/packages/react-start-client/src/tests/Hydrate.test.tsx b/packages/react-start-client/src/tests/Hydrate.test.tsx index 0d0ce1faad..22fb43e450 100644 --- a/packages/react-start-client/src/tests/Hydrate.test.tsx +++ b/packages/react-start-client/src/tests/Hydrate.test.tsx @@ -9,7 +9,7 @@ import { screen, waitFor, } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { hydrateIdAttribute } from '@tanstack/start-client-core/hydration/constants' import { Hydrate } from '../Hydrate' import { condition, idle, interaction, load, never } from '../hydration' @@ -122,6 +122,31 @@ async function renderAsync(ui: React.ReactElement) { }) } +function createRootCleanup( + container: Element, + getRoot: () => ReturnType | undefined, +) { + let cleanedUp = false + + return async () => { + if (cleanedUp) { + return + } + cleanedUp = true + + try { + const root = getRoot() + if (root) { + await act(() => { + root.unmount() + }) + } + } finally { + container.remove() + } + } +} + async function hydrateFromServer(ui: React.ReactElement) { vi.stubGlobal('window', undefined) const html = renderToString(ui) @@ -131,23 +156,16 @@ async function hydrateFromServer(ui: React.ReactElement) { document.body.append(container) container.innerHTML = html - let root!: ReturnType + let root: ReturnType | undefined + const cleanupRoot = createRootCleanup(container, () => root) + onTestFinished(cleanupRoot) + await act(async () => { root = hydrateRoot(container, ui) await Promise.resolve() }) - return { container, html, root } -} - -async function unmountHydratedRoot( - root: ReturnType, - container: Element, -) { - await act(async () => { - root.unmount() - }) - container.remove() + return { container, html, cleanupRoot } } afterEach(() => { @@ -157,7 +175,7 @@ afterEach(() => { describe('Hydrate', () => { it('uses a single custom interaction event instead of the default intent events', async () => { - const { container, html, root } = await hydrateFromServer( + const { html } = await hydrateFromServer( fallback} @@ -166,30 +184,26 @@ describe('Hydrate', () => { , ) - try { - expect(html).toContain('data-testid="child"') - expect(html).not.toContain('data-testid="fallback"') - expect(screen.queryByTestId('fallback')).toBeNull() - await expectNoHydrationAfterDefaultIntentEvents() - - await fireIntent(() => - getMarker().dispatchEvent( - new MouseEvent('dblclick', { bubbles: true, cancelable: true }), - ), - ) + expect(html).toContain('data-testid="child"') + expect(html).not.toContain('data-testid="fallback"') + expect(screen.queryByTestId('fallback')).toBeNull() + await expectNoHydrationAfterDefaultIntentEvents() - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) - } finally { - await unmountHydratedRoot(root, container) - } + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) }) it('uses every event in a custom interaction event list', async () => { - const { container, root } = await hydrateFromServer( + await hydrateFromServer( fallback} @@ -198,24 +212,20 @@ describe('Hydrate', () => { , ) - try { - expect(screen.queryByTestId('fallback')).toBeNull() - await expectNoHydrationAfterDefaultIntentEvents() + expect(screen.queryByTestId('fallback')).toBeNull() + await expectNoHydrationAfterDefaultIntentEvents() - await fireIntent(() => - getMarker().dispatchEvent( - new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), - ), - ) + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), + ), + ) - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) - } finally { - await unmountHydratedRoot(root, container) - } + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) }) it('omits never content when mounted after the app is already hydrated', async () => { @@ -253,7 +263,7 @@ describe('Hydrate', () => { }) it('does not use fallback for an initial never boundary', async () => { - const { container, html, root } = await hydrateFromServer( + const { html } = await hydrateFromServer( fallback} @@ -262,24 +272,20 @@ describe('Hydrate', () => { , ) - try { - expect(html).toContain('data-testid="child"') - expect(html).not.toContain('data-testid="fallback"') - expect(screen.queryByTestId('fallback')).toBeNull() + expect(html).toContain('data-testid="child"') + expect(html).not.toContain('data-testid="fallback"') + expect(screen.queryByTestId('fallback')).toBeNull() - fireEvent.click(screen.getByTestId('child')) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'false', - ) - expect(screen.getByTestId('child').textContent).toBe('0') - } finally { - await unmountHydratedRoot(root, container) - } + fireEvent.click(screen.getByTestId('child')) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) + expect(screen.getByTestId('child').textContent).toBe('0') }) it('keeps repeated split boundaries independently gated', async () => { - const { container, root } = await hydrateFromServer( + const { container } = await hydrateFromServer( <> @@ -290,37 +296,33 @@ describe('Hydrate', () => { , ) - try { - const markers = container.querySelectorAll(hydrateIdSelector) + const markers = container.querySelectorAll(hydrateIdSelector) - expect(markers).toHaveLength(2) - expect(markers[0]!.getAttribute(hydrateIdAttribute)).not.toBe( - markers[1]!.getAttribute(hydrateIdAttribute), - ) - expect( - screen.getByTestId('child-one').getAttribute('data-hydrated'), - ).toBe('false') - expect( - screen.getByTestId('child-two').getAttribute('data-hydrated'), - ).toBe('false') + expect(markers).toHaveLength(2) + expect(markers[0]!.getAttribute(hydrateIdAttribute)).not.toBe( + markers[1]!.getAttribute(hydrateIdAttribute), + ) + expect(screen.getByTestId('child-one').getAttribute('data-hydrated')).toBe( + 'false', + ) + expect(screen.getByTestId('child-two').getAttribute('data-hydrated')).toBe( + 'false', + ) - await fireIntent(() => - markers[0]!.dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ), - ) + await fireIntent(() => + markers[0]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) - await waitFor(() => - expect( - screen.getByTestId('child-one').getAttribute('data-hydrated'), - ).toBe('true'), - ) + await waitFor(() => expect( - screen.getByTestId('child-two').getAttribute('data-hydrated'), - ).toBe('false') - } finally { - await unmountHydratedRoot(root, container) - } + screen.getByTestId('child-one').getAttribute('data-hydrated'), + ).toBe('true'), + ) + expect(screen.getByTestId('child-two').getAttribute('data-hydrated')).toBe( + 'false', + ) }) it('fires onHydrated once after the client hydration commit', async () => { @@ -341,8 +343,11 @@ describe('Hydrate', () => { document.body.append(container) container.innerHTML = html - let root!: ReturnType - await act(async () => { + let root: ReturnType | undefined + const cleanupRoot = createRootCleanup(container, () => root) + onTestFinished(cleanupRoot) + + await act(() => { root = hydrateRoot(container, app) }) @@ -351,17 +356,12 @@ describe('Hydrate', () => { fireEvent.click(screen.getByTestId('child')) await new Promise((resolve) => setTimeout(resolve, 20)) expect(onHydrated).toHaveBeenCalledTimes(1) - - await act(async () => { - root.unmount() - }) - container.remove() }) it('prefetches split children without hydrating the boundary', async () => { const preload = vi.fn(() => Promise.resolve()) - const { container, root } = await hydrateFromServer( + await hydrateFromServer( { , ) - try { - await waitFor(() => expect(preload).toHaveBeenCalledTimes(1)) - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'false', - ) + await waitFor(() => expect(preload).toHaveBeenCalledTimes(1)) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) - await fireIntent(() => - getMarker().dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ), - ) + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) - expect(preload).toHaveBeenCalledTimes(1) - } finally { - await unmountHydratedRoot(root, container) - } + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + expect(preload).toHaveBeenCalledTimes(1) }) it('does not evaluate dynamic when callbacks on the server', async () => { @@ -412,35 +408,34 @@ describe('Hydrate', () => { document.body.append(container) container.innerHTML = html - let root!: ReturnType - try { - await act(async () => { - root = hydrateRoot( - container, - - - , - ) - await Promise.resolve() - }) - - expect(when).toHaveBeenCalled() - await expectNoHydrationAfterDefaultIntentEvents() - - await fireIntent(() => - getMarker().dispatchEvent( - new MouseEvent('dblclick', { bubbles: true, cancelable: true }), - ), - ) + let root: ReturnType | undefined + const cleanupRoot = createRootCleanup(container, () => root) + onTestFinished(cleanupRoot) - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), + await act(async () => { + root = hydrateRoot( + container, + + + , ) - } finally { - await unmountHydratedRoot(root, container) - } + await Promise.resolve() + }) + + expect(when).toHaveBeenCalled() + await expectNoHydrationAfterDefaultIntentEvents() + + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) }) it('replays an interaction captured before the Hydrate component hydrates', async () => { @@ -458,6 +453,10 @@ describe('Hydrate', () => { document.body.append(container) container.innerHTML = html + let root: ReturnType | undefined + const cleanupRoot = createRootCleanup(container, () => root) + onTestFinished(cleanupRoot) + const button = container.querySelector('[data-testid="child"]') if (!button) { throw new Error('Expected server-rendered child button') @@ -467,29 +466,24 @@ describe('Hydrate', () => { new MouseEvent('click', { bubbles: true, cancelable: true }), ) - let root!: ReturnType - try { - await act(async () => { - root = hydrateRoot( - container, - - - , - ) - await Promise.resolve() - }) - - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) - await waitFor(() => - expect(screen.getByTestId('child').textContent).toBe('1'), + await act(async () => { + root = hydrateRoot( + container, + + + , ) - } finally { - await unmountHydratedRoot(root, container) - } + await Promise.resolve() + }) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + await waitFor(() => + expect(screen.getByTestId('child').textContent).toBe('1'), + ) }) it('blocks hydration on awaited procedural prefetch work', async () => { @@ -504,7 +498,7 @@ describe('Hydrate', () => { _s: () => () => {}, } as HydrationPrefetchStrategy<'idle'> - const { container, root } = await hydrateFromServer( + await hydrateFromServer( { @@ -518,33 +512,29 @@ describe('Hydrate', () => { , ) - try { - await fireIntent(() => - getMarker().dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ), - ) + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) - await waitFor(() => expect(waitReasons).toEqual(['hydrate'])) - expect(preload).toHaveBeenCalledTimes(1) - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'false', - ) + await waitFor(() => expect(waitReasons).toEqual(['hydrate'])) + expect(preload).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) - await act(async () => { - resolvePrefetch() - await prefetchBlocker - await Promise.resolve() - }) + await act(async () => { + resolvePrefetch() + await prefetchBlocker + await Promise.resolve() + }) - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) - } finally { - await unmountHydratedRoot(root, container) - } + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) }) it('hydrates when a condition strategy changes after the initial render', async () => { @@ -563,26 +553,22 @@ describe('Hydrate', () => { ) } - const { container, root } = await hydrateFromServer() + await hydrateFromServer() - try { - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'false', - ) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) - await act(async () => { - fireEvent.click(screen.getByTestId('ready')) - await Promise.resolve() - }) + await act(async () => { + fireEvent.click(screen.getByTestId('ready')) + await Promise.resolve() + }) - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) - } finally { - await unmountHydratedRoot(root, container) - } + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) }) it('does not block hydration on fire-and-forget procedural prefetch work', async () => { @@ -591,7 +577,7 @@ describe('Hydrate', () => { resolvePrefetch = resolve }) - const { container, root } = await hydrateFromServer( + await hydrateFromServer( { @@ -602,32 +588,28 @@ describe('Hydrate', () => { , ) - try { - await fireIntent(() => - getMarker().dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ), - ) + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) - await act(async () => { - resolvePrefetch() - await prefetchBlocker - }) - } finally { - await unmountHydratedRoot(root, container) - } + await act(async () => { + resolvePrefetch() + await prefetchBlocker + }) }) it('aborts procedural prefetch when the boundary unmounts', async () => { const signals: Array = [] - const { container, root } = await hydrateFromServer( + const { cleanupRoot } = await hydrateFromServer( { @@ -642,12 +624,12 @@ describe('Hydrate', () => { expect(signals).toHaveLength(1) expect(signals[0]!.aborted).toBe(false) - await unmountHydratedRoot(root, container) + await cleanupRoot() expect(signals[0]!.aborted).toBe(true) }) it('delegates nested interaction boundaries at runtime', async () => { - const { container, root } = await hydrateFromServer( + await hydrateFromServer( @@ -655,22 +637,18 @@ describe('Hydrate', () => { , ) - try { - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'false', - ) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) - await fireIntent(() => { - fireEvent.click(screen.getByTestId('child')) - }) + await fireIntent(() => { + fireEvent.click(screen.getByTestId('child')) + }) - await waitFor(() => - expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( - 'true', - ), - ) - } finally { - await unmountHydratedRoot(root, container) - } + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) }) }) diff --git a/packages/react-start-client/src/tests/hydrateStart.test.ts b/packages/react-start-client/src/tests/hydrateStart.test.ts index 2d34400009..696d279af9 100644 --- a/packages/react-start-client/src/tests/hydrateStart.test.ts +++ b/packages/react-start-client/src/tests/hydrateStart.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { hydrateStart } from '../hydrateStart' const coreHydrateStart = vi.hoisted(() => vi.fn()) @@ -7,12 +7,12 @@ vi.mock('@tanstack/start-client-core/client', () => ({ hydrateStart: coreHydrateStart, })) -afterEach(() => { - delete window.$_TSR - coreHydrateStart.mockReset() -}) - test('signals streaming cleanup after hydration succeeds', async () => { + onTestFinished(() => { + delete window.$_TSR + coreHydrateStart.mockReset() + }) + const router = {} coreHydrateStart.mockResolvedValue(router) const hydrated = vi.fn() @@ -23,6 +23,11 @@ test('signals streaming cleanup after hydration succeeds', async () => { }) test('signals streaming cleanup without hiding a hydration failure', async () => { + onTestFinished(() => { + delete window.$_TSR + coreHydrateStart.mockReset() + }) + const error = new Error('hydration failed') coreHydrateStart.mockRejectedValue(error) const hydrated = vi.fn() diff --git a/packages/router-core/tests/background-assets-stale.test.ts b/packages/router-core/tests/background-assets-stale.test.ts index 1c39969185..72355e447e 100644 --- a/packages/router-core/tests/background-assets-stale.test.ts +++ b/packages/router-core/tests/background-assets-stale.test.ts @@ -1,22 +1,21 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' describe('background decorative asset failure', () => { - beforeEach(() => { + test('commits fresh loader data while preserving the previous projected assets', async () => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(0) - }) - afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - }) - - test('commits fresh loader data while preserving the previous projected assets', async () => { const projectionError = new Error('head projection failed') const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + log.mockRestore() + }) let resolveStaleReload!: (data: { title: string }) => void let loaderCalls = 0 const loader = () => { diff --git a/packages/router-core/tests/blocked-navigation-current-load.test.ts b/packages/router-core/tests/blocked-navigation-current-load.test.ts index e307f35934..976e82b474 100644 --- a/packages/router-core/tests/blocked-navigation-current-load.test.ts +++ b/packages/router-core/tests/blocked-navigation-current-load.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, onTestFinished, test } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' @@ -43,33 +43,39 @@ describe('blocked navigation does not cancel the current load', () => { // loads. A blocked commit never reaches this subscriber. const unsubscribe = router.history.subscribe(router.load) let unblock: (() => void) | undefined + let navigation: Promise | undefined + let blockedNavigation: Promise | undefined + onTestFinished(async () => { + loaderGate.resolve('slow data') + unblock?.() + await Promise.all([ + navigation?.catch(() => undefined), + blockedNavigation?.catch(() => undefined), + ]) + unsubscribe() + }) - try { - const navigation = router.navigate({ to: '/slow' }) + navigation = router.navigate({ to: '/slow' }) - // Discard every later navigation commit. The blocker is async, so the - // discarded commit still sets _pendingLocation for one microtask. - unblock = router.history.block({ blockerFn: async () => true }) + // Discard every later navigation commit. The blocker is async, so the + // discarded commit still sets _pendingLocation for one microtask. + unblock = router.history.block({ blockerFn: async () => true }) - // Same tick: settle the loader, then issue a navigation the blocker - // discards. The loader continuation resumes inside the window where - // _pendingLocation still points at /other. - loaderGate.resolve('slow data') - const blockedNavigation = router.navigate({ to: '/other' }) + // Same tick: settle the loader, then issue a navigation the blocker + // discards. The loader continuation resumes inside the window where + // _pendingLocation still points at /other. + loaderGate.resolve('slow data') + blockedNavigation = router.navigate({ to: '/other' }) - await Promise.all([navigation, blockedNavigation]) + await Promise.all([navigation, blockedNavigation]) - expect(router.state.location.pathname).toBe('/slow') - expect(router.history.location.pathname).toBe('/slow') - expect( - router.state.matches.find((m) => m.routeId === slowRoute.id), - ).toMatchObject({ - status: 'success', - loaderData: 'slow data', - }) - } finally { - unblock?.() - unsubscribe() - } + expect(router.state.location.pathname).toBe('/slow') + expect(router.history.location.pathname).toBe('/slow') + expect( + router.state.matches.find((m) => m.routeId === slowRoute.id), + ).toMatchObject({ + status: 'success', + loaderData: 'slow data', + }) }) }) diff --git a/packages/router-core/tests/boundary-component-chunk.test.ts b/packages/router-core/tests/boundary-component-chunk.test.ts index b8e8322ca6..29aae8da58 100644 --- a/packages/router-core/tests/boundary-component-chunk.test.ts +++ b/packages/router-core/tests/boundary-component-chunk.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -19,27 +19,12 @@ import { createTestRouter } from './routerTestUtils' * error state to be committed without waiting for the normal component chunk. */ -const pendingGates: Array>> = [] -const pendingLoads: Array> = [] - -afterEach(async () => { - for (const gate of pendingGates) { - gate.resolve() - } - - await Promise.allSettled(pendingLoads) - - pendingGates.length = 0 - pendingLoads.length = 0 -}) - describe('route boundary component preloads', () => { test('errorComponent preload resolves without waiting for a pending route component preload', async () => { const componentGate = createControlledPromise() const errorComponentGate = createControlledPromise() const routeError = new Error('loader failed') let errorComponentPreloadCalls = 0 - pendingGates.push(componentGate, errorComponentGate) const SlowRouteComponent = Object.assign(() => null, { preload: () => componentGate, @@ -67,7 +52,11 @@ describe('route boundary component preloads', () => { }) const loadPromise = router.load() - pendingLoads.push(loadPromise) + onTestFinished(async () => { + componentGate.resolve() + errorComponentGate.resolve() + await Promise.allSettled([loadPromise]) + }) await vi.waitFor(() => expect(errorComponentPreloadCalls).toBe(1)) errorComponentGate.resolve() @@ -90,7 +79,6 @@ describe('route boundary component preloads', () => { const componentError = new Error('component chunk failed') const onError = vi.fn() let errorComponentPreloadCalls = 0 - pendingGates.push(errorComponentGate) const SlowRouteComponent = Object.assign(() => null, { preload: () => componentGate, @@ -119,7 +107,10 @@ describe('route boundary component preloads', () => { }) const loadPromise = router.load() - pendingLoads.push(loadPromise) + onTestFinished(async () => { + errorComponentGate.resolve() + await Promise.allSettled([loadPromise]) + }) await vi.waitFor(() => expect(errorComponentPreloadCalls).toBe(1)) errorComponentGate.resolve() @@ -141,7 +132,6 @@ describe('route boundary component preloads', () => { test('global notFound does not wait for component chunks below its boundary', async () => { const hiddenComponentGate = createControlledPromise() const notFoundPreload = vi.fn(() => Promise.resolve()) - pendingGates.push(hiddenComponentGate) const NotFoundBoundary = Object.assign(() => null, { preload: notFoundPreload, @@ -170,7 +160,10 @@ describe('route boundary component preloads', () => { }) const loading = router.load() - pendingLoads.push(loading) + onTestFinished(async () => { + hiddenComponentGate.resolve() + await Promise.allSettled([loading]) + }) await loading expect(hiddenComponentGate.status).toBe('pending') @@ -184,7 +177,6 @@ describe('route boundary component preloads', () => { const componentGate = createControlledPromise() const notFoundGate = createControlledPromise() const notFoundPreload = vi.fn(() => notFoundGate) - pendingGates.push(componentGate, notFoundGate) const ParentComponent = Object.assign(() => null, { preload: () => componentGate, @@ -214,7 +206,11 @@ describe('route boundary component preloads', () => { }) const loading = router.load() - pendingLoads.push(loading) + onTestFinished(async () => { + componentGate.resolve() + notFoundGate.resolve() + await Promise.allSettled([loading]) + }) await vi.waitFor(() => expect(notFoundPreload).toHaveBeenCalledOnce()) componentGate.resolve() diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index c35937259c..0585ec8576 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -1766,20 +1766,17 @@ describe('buildLocation - params edge cases', () => { await router.load() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) - try { - const location = router.buildLocation({ - to: '/$foo', - params: { foo: 'yes' }, - }) - - expect(location.pathname).toBe('/no') - expect(warn).toHaveBeenCalledWith( - 'Generated path "/no" for route "/$foo" matched route "/no" instead. This can happen when multiple route templates resolve to the same URL. Use the route template that matches the intended route, or adjust params.stringify if it changed the target path.', - ) - } finally { - warn.mockRestore() - } + const location = router.buildLocation({ + to: '/$foo', + params: { foo: 'yes' }, + }) + + expect(location.pathname).toBe('/no') + expect(warn).toHaveBeenCalledWith( + 'Generated path "/no" for route "/$foo" matched route "/no" instead. This can happen when multiple route templates resolve to the same URL. Use the route template that matches the intended route, or adjust params.stringify if it changed the target path.', + ) }) test('buildLocation should warn in development when a generated path matches an optional route template', async () => { @@ -1803,19 +1800,16 @@ describe('buildLocation - params edge cases', () => { await router.load() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) - try { - const location = router.buildLocation({ - to: '/time', - }) - - expect(location.pathname).toBe('/time') - expect(warn).toHaveBeenCalledWith( - 'Generated path "/time" for route "/time" matched route "/time/{-$day}" instead. This can happen when multiple route templates resolve to the same URL. Use the route template that matches the intended route, or adjust params.stringify if it changed the target path.', - ) - } finally { - warn.mockRestore() - } + const location = router.buildLocation({ + to: '/time', + }) + + expect(location.pathname).toBe('/time') + expect(warn).toHaveBeenCalledWith( + 'Generated path "/time" for route "/time" matched route "/time/{-$day}" instead. This can happen when multiple route templates resolve to the same URL. Use the route template that matches the intended route, or adjust params.stringify if it changed the target path.', + ) }) test('params.stringify in nested routes should all be applied', async () => { diff --git a/packages/router-core/tests/client-lane-adversarial.test.ts b/packages/router-core/tests/client-lane-adversarial.test.ts index acfe3fede4..a4cdcf3890 100644 --- a/packages/router-core/tests/client-lane-adversarial.test.ts +++ b/packages/router-core/tests/client-lane-adversarial.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -9,10 +9,6 @@ import { } from '../src' import { createTestRouter, loadServerResponse } from './routerTestUtils' -afterEach(() => { - vi.useRealTimers() -}) - function abortAwareGate(signal: AbortSignal): Promise { return new Promise((_resolve, reject) => { signal.addEventListener( diff --git a/packages/router-core/tests/error-boundary-cache-generation.test.ts b/packages/router-core/tests/error-boundary-cache-generation.test.ts index 447dace6ea..b36103e3b6 100644 --- a/packages/router-core/tests/error-boundary-cache-generation.test.ts +++ b/packages/router-core/tests/error-boundary-cache-generation.test.ts @@ -1,12 +1,8 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.restoreAllMocks() -}) - describe('cache retention across an error-boundary commit', () => { test('a superseded generation cannot shadow newer beyond-boundary data', async () => { let parentFails = false diff --git a/packages/router-core/tests/hmr-refresh-lifecycle.test.ts b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts index ad7c743bf3..25bc4c3b62 100644 --- a/packages/router-core/tests/hmr-refresh-lifecycle.test.ts +++ b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts @@ -48,9 +48,11 @@ describe('HMR route refresh', () => { await router.load() const unsubLoad = router.subscribe('onLoad', () => order.push('onLoad')) + onTestFinished(unsubLoad) const unsubMount = router.subscribe('onBeforeRouteMount', () => order.push('onBeforeRouteMount'), ) + onTestFinished(unsubMount) generation = 2 await router._refreshRoute!() @@ -60,8 +62,6 @@ describe('HMR route refresh', () => { expect(onLeave).not.toHaveBeenCalled() expect(onStay).toHaveBeenCalledTimes(1) expect(order).toEqual(['onStay', 'onLoad', 'onBeforeRouteMount']) - unsubLoad() - unsubMount() }) test('retires refresh mode after an acknowledged publication', async () => { diff --git a/packages/router-core/tests/hydration-asset-context-order.test.ts b/packages/router-core/tests/hydration-asset-context-order.test.ts index 64ea28971e..1a847ebd04 100644 --- a/packages/router-core/tests/hydration-asset-context-order.test.ts +++ b/packages/router-core/tests/hydration-asset-context-order.test.ts @@ -1,5 +1,5 @@ import { runInNewContext } from 'node:vm' -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { hydrate } from '../src/ssr/client' @@ -33,11 +33,6 @@ async function dehydrateToBootstrap(router: AnyRouter): Promise { } } -afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - test('hydration reconstructs every match context before ancestor head reads the lane', async () => { const serverBeforeLoad = vi.fn(() => ({ user: 'server authenticated user', @@ -81,6 +76,9 @@ test('hydration reconstructs every match context before ancestor head reads the }) vi.stubGlobal('window', { $_TSR: bootstrap }) + onTestFinished(() => { + vi.unstubAllGlobals() + }) await hydrate(router) diff --git a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts index a9f56ba8d3..97c8de7e4e 100644 --- a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts +++ b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts @@ -54,46 +54,45 @@ describe('issue #6221: head does not run before loader data is ready', () => { const backLoadFinished = createControlledPromise() let backLoadStarted = false let unsubscribe: (() => void) | undefined - - try { - await router.load() - const notFoundMatch = router.state.matches.find( - (match) => match.routeId === articleRoute.id, - ) - expect(notFoundMatch?.status).toBe('notFound') - expect(notFoundMatch?.meta).toEqual([{ title: 'Generic title' }]) - - // Model the reported auth redirect and browser Back without relying on a - // wall-clock loader delay. - authed = true - await router.navigate({ to: '/dashboard' }) - unsubscribe = router.history.subscribe(() => { - backLoadStarted = true - void router.load().then( - () => backLoadFinished.resolve(), - (error) => backLoadFinished.reject(error), - ) - }) - router.history.back() - await successfulLoadStarted - expect(articleResponse.status).toBe('pending') - - articleResponse.resolve({ title: 'Article 123' }) - await backLoadFinished - - const articleMatch = router.state.matches.find( - (match) => match.routeId === articleRoute.id, - ) - expect(articleMatch?.status).toBe('success') - expect(articleMatch?.loaderData).toEqual({ title: 'Article 123' }) - expect(articleMatch?.meta).toEqual([{ title: 'Article 123' }]) - } finally { + onTestFinished(async () => { articleResponse.resolve({ title: 'Article 123' }) if (backLoadStarted) { await backLoadFinished.catch(() => undefined) } unsubscribe?.() - } + }) + + await router.load() + const notFoundMatch = router.state.matches.find( + (match) => match.routeId === articleRoute.id, + ) + expect(notFoundMatch?.status).toBe('notFound') + expect(notFoundMatch?.meta).toEqual([{ title: 'Generic title' }]) + + // Model the reported auth redirect and browser Back without relying on a + // wall-clock loader delay. + authed = true + await router.navigate({ to: '/dashboard' }) + unsubscribe = router.history.subscribe(() => { + backLoadStarted = true + void router.load().then( + () => backLoadFinished.resolve(), + (error) => backLoadFinished.reject(error), + ) + }) + router.history.back() + await successfulLoadStarted + expect(articleResponse.status).toBe('pending') + + articleResponse.resolve({ title: 'Article 123' }) + await backLoadFinished + + const articleMatch = router.state.matches.find( + (match) => match.routeId === articleRoute.id, + ) + expect(articleMatch?.status).toBe('success') + expect(articleMatch?.loaderData).toEqual({ title: 'Article 123' }) + expect(articleMatch?.meta).toEqual([{ title: 'Article 123' }]) }) test('head title does not lag one loaderData run behind on revisits', async () => { diff --git a/packages/router-core/tests/masked-location-state-commit.test.ts b/packages/router-core/tests/masked-location-state-commit.test.ts index 9738b42261..97a857f4e1 100644 --- a/packages/router-core/tests/masked-location-state-commit.test.ts +++ b/packages/router-core/tests/masked-location-state-commit.test.ts @@ -1,12 +1,8 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, test } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.restoreAllMocks() -}) - describe('masked location remnants in history state', () => { test('a same-href navigation clears an expired mask from history state', async () => { const makeRoutes = () => { diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts index 94d31e2e94..dcb91bf760 100644 --- a/packages/router-core/tests/preload-adoption.test.ts +++ b/packages/router-core/tests/preload-adoption.test.ts @@ -1,12 +1,8 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.useRealTimers() -}) - /** * Preload adoption edge cases. The happy path (navigation adopts an * in-flight preload's successful loader run) and the control-flow @@ -20,6 +16,9 @@ afterEach(() => { describe('preload adoption', () => { test('navigation waits for fresh data from an in-flight stale preload revalidation', async () => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(0) const revalidationGate = createControlledPromise<{ diff --git a/packages/router-core/tests/preload-beforeload-reuse.test.ts b/packages/router-core/tests/preload-beforeload-reuse.test.ts index c9d70bae5d..20b5a0ac24 100644 --- a/packages/router-core/tests/preload-beforeload-reuse.test.ts +++ b/packages/router-core/tests/preload-beforeload-reuse.test.ts @@ -1,12 +1,8 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter, loadServerResponse } from './routerTestUtils' -afterEach(() => { - vi.useRealTimers() -}) - describe('preloaded loader reuse with fresh beforeLoad context', () => { test('reruns nested context for navigation while reusing loader data', async () => { const parentBeforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ @@ -140,6 +136,9 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { 'reruns completed beforeLoad while retaining navigation-owned loader data at age $age', async ({ age, expected, guard }) => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(1_000) const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ guard: preload ? 'preloaded' : 'loaded', @@ -286,6 +285,9 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { test('reruns beforeLoad when the completed preload reaches its stale boundary', async () => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(1_000) const seen: Array = [] const rootRoute = new BaseRootRoute({}) @@ -321,6 +323,9 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { test('reruns descendant context after an ancestor becomes stale', async () => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(1_000) const parentSeen: Array = [] const childSeen: Array = [] @@ -534,6 +539,9 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { test('shouldReload false does not keep stale beforeLoad context', async () => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(1_000) const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ guard: preload ? 'preloaded' : 'loaded', diff --git a/packages/router-core/tests/preload-public-cache-behavior.test.ts b/packages/router-core/tests/preload-public-cache-behavior.test.ts index 2ad8017ee5..ebe5a5463b 100644 --- a/packages/router-core/tests/preload-public-cache-behavior.test.ts +++ b/packages/router-core/tests/preload-public-cache-behavior.test.ts @@ -1,18 +1,16 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - // https://github.com/TanStack/router/issues/2980 // Repeated child preloads must borrow a stale active parent instead of rerunning // its loader. test('#2980: repeated child preloads do not rerun a stale active parent loader', async () => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(1_000) const layoutLoader = vi.fn(() => 'layout data') @@ -60,6 +58,9 @@ test('#2980: repeated child preloads do not rerun a stale active parent loader', // cache entry's lifetime. test('fresh navigation data keeps its gc policy when a preload reuses it', async () => { vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) vi.setSystemTime(1_000) let revision = 0 diff --git a/packages/router-core/tests/public-client-loading-contract.test.ts b/packages/router-core/tests/public-client-loading-contract.test.ts index c3cb0c7577..d8cd243958 100644 --- a/packages/router-core/tests/public-client-loading-contract.test.ts +++ b/packages/router-core/tests/public-client-loading-contract.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -8,11 +8,6 @@ import { } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - describe('public client loading contracts', () => { test('blocking loading is observable through match isFetching', async () => { const loaderGate = createControlledPromise() diff --git a/packages/router-core/tests/public-preload-lane-contract.test.ts b/packages/router-core/tests/public-preload-lane-contract.test.ts index 9be86c1b03..2abe64067d 100644 --- a/packages/router-core/tests/public-preload-lane-contract.test.ts +++ b/packages/router-core/tests/public-preload-lane-contract.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -9,10 +9,6 @@ import { } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.useRealTimers() -}) - describe('public preload lane contracts', () => { test('completed preload matches retain context without caching beforeLoad context', async () => { let beforeLoadGeneration = 0 @@ -324,42 +320,7 @@ describe('public preload lane contracts', () => { let preload: Promise | undefined let navigation: Promise | undefined - try { - await router.load() - preload = router.preloadRoute({ - to: '/target', - mask: preloadMask as any, - }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) - - beforeLoadGate.resolve() - await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) - - navigation = router.navigate({ - to: '/target', - mask: navigationMask as any, - }) - await vi.waitFor(() => - expect( - beforeLoad.mock.calls.map(([callContext]) => ({ - preload: callContext.preload, - pathname: callContext.location.maskedLocation?.pathname, - })), - ).toEqual([ - { preload: true, pathname: preloadPathname }, - { preload: false, pathname: navigationPathname }, - ]), - ) - - loaderGate.resolve('shared loader data') - await Promise.all([preload, navigation]) - - expect(router.state.matches.at(-1)?.context).toEqual({ - source: 'navigation', - }) - expect(router.history.location.pathname).toBe(navigationPathname) - expect(loader).toHaveBeenCalledTimes(1) - } finally { + onTestFinished(async () => { beforeLoadGate.resolve() loaderGate.resolve('shared loader data') const activeWork: Array> = [] @@ -370,7 +331,42 @@ describe('public preload lane contracts', () => { activeWork.push(navigation) } await Promise.allSettled(activeWork) - } + }) + + await router.load() + preload = router.preloadRoute({ + to: '/target', + mask: preloadMask as any, + }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + + beforeLoadGate.resolve() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + navigation = router.navigate({ + to: '/target', + mask: navigationMask as any, + }) + await vi.waitFor(() => + expect( + beforeLoad.mock.calls.map(([callContext]) => ({ + preload: callContext.preload, + pathname: callContext.location.maskedLocation?.pathname, + })), + ).toEqual([ + { preload: true, pathname: preloadPathname }, + { preload: false, pathname: navigationPathname }, + ]), + ) + + loaderGate.resolve('shared loader data') + await Promise.all([preload, navigation]) + + expect(router.state.matches.at(-1)?.context).toEqual({ + source: 'navigation', + }) + expect(router.history.location.pathname).toBe(navigationPathname) + expect(loader).toHaveBeenCalledTimes(1) }, ) diff --git a/packages/router-core/tests/same-destination-navigation-join.test.ts b/packages/router-core/tests/same-destination-navigation-join.test.ts index c6d65fe525..127a1078e3 100644 --- a/packages/router-core/tests/same-destination-navigation-join.test.ts +++ b/packages/router-core/tests/same-destination-navigation-join.test.ts @@ -1,12 +1,8 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.restoreAllMocks() -}) - describe('same-destination navigation while one is in flight', () => { function setup() { const gate = createControlledPromise() diff --git a/packages/router-core/tests/scroll-restoration.test.ts b/packages/router-core/tests/scroll-restoration.test.ts index c02a8f8783..c8d6a76a54 100644 --- a/packages/router-core/tests/scroll-restoration.test.ts +++ b/packages/router-core/tests/scroll-restoration.test.ts @@ -1,5 +1,5 @@ import { createMemoryHistory } from '@tanstack/history' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' import type { ParsedLocation } from '../src' @@ -63,6 +63,9 @@ describe('setupScrollRestoration', () => { const windowAddEventListener = vi.spyOn(window, 'addEventListener') const documentAddEventListener = vi.spyOn(document, 'addEventListener') const previousScrollRestoration = window.history.scrollRestoration + onTestFinished(() => { + window.history.scrollRestoration = previousScrollRestoration + }) window.history.scrollRestoration = 'auto' @@ -79,8 +82,6 @@ describe('setupScrollRestoration', () => { ([event, _listener, options]) => event === 'scroll' && options === true, ), ).toBe(true) - - window.history.scrollRestoration = previousScrollRestoration }) test('snapshots the live position when it changed after the latest scroll event', () => { @@ -149,6 +150,9 @@ describe('setupScrollRestoration', () => { const windowAddEventListener = vi.spyOn(window, 'addEventListener') const documentAddEventListener = vi.spyOn(document, 'addEventListener') const previousScrollRestoration = window.history.scrollRestoration + onTestFinished(() => { + window.history.scrollRestoration = previousScrollRestoration + }) window.history.scrollRestoration = 'auto' @@ -171,8 +175,6 @@ describe('setupScrollRestoration', () => { event === 'scroll' && options === true, ), ).toBe(false) - - window.history.scrollRestoration = previousScrollRestoration }, ) diff --git a/packages/router-core/tests/searchParams.test.ts b/packages/router-core/tests/searchParams.test.ts index e56f8b7131..1776b991bb 100644 --- a/packages/router-core/tests/searchParams.test.ts +++ b/packages/router-core/tests/searchParams.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { defaultParseSearch, defaultStringifySearch, @@ -83,30 +83,27 @@ describe('Search Params serialization and deserialization', () => { test('skips JSON.parse for strings that cannot be JSON', () => { const parseSpy = vi.spyOn(JSON, 'parse') - try { - const stringify = stringifySearchWith(JSON.stringify, JSON.parse) + onTestFinished(() => parseSpy.mockRestore()) + const stringify = stringifySearchWith(JSON.stringify, JSON.parse) - expect( - stringify({ - empty: '', - filter: 'foo', - future: 'future', - name: 'name', - notification: 'new', - tab: 'tabular', - topic: 'topic', - unicode: '雪', - }), - ).toEqual( - '?empty=&filter=foo&future=future&name=name¬ification=new&tab=tabular&topic=topic&unicode=%E9%9B%AA', - ) - expect( - stringify({ file: '.env', path: '/products', positive: '+1' }), - ).toEqual('?file=.env&path=%2Fproducts&positive=%2B1') - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } + expect( + stringify({ + empty: '', + filter: 'foo', + future: 'future', + name: 'name', + notification: 'new', + tab: 'tabular', + topic: 'topic', + unicode: '雪', + }), + ).toEqual( + '?empty=&filter=foo&future=future&name=name¬ification=new&tab=tabular&topic=topic&unicode=%E9%9B%AA', + ) + expect( + stringify({ file: '.env', path: '/products', positive: '+1' }), + ).toEqual('?file=.env&path=%2Fproducts&positive=%2B1') + expect(parseSpy).not.toHaveBeenCalled() }) test('[edge case] self-reference serializes to "object Object"', () => { diff --git a/packages/router-core/tests/server-async-headers-decorative-hang.test.ts b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts index 454e6a6bb8..eef54db2c0 100644 --- a/packages/router-core/tests/server-async-headers-decorative-hang.test.ts +++ b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts @@ -1,15 +1,14 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter, loadServerResponse } from './routerTestUtils' -afterEach(() => { - vi.restoreAllMocks() -}) - test('a rejected projection hook is logged without failing the response', async () => { const projectionError = new Error('scripts failed') const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + log.mockRestore() + }) const headGate = createControlledPromise<{ meta: Array<{ title: string }> }>() diff --git a/packages/router-core/tests/ssr-server-cleanup.test.ts b/packages/router-core/tests/ssr-server-cleanup.test.ts index a4d0b96588..a4abea62de 100644 --- a/packages/router-core/tests/ssr-server-cleanup.test.ts +++ b/packages/router-core/tests/ssr-server-cleanup.test.ts @@ -1,5 +1,5 @@ import { createMemoryHistory } from '@tanstack/history' -import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' import { createRequestHandler } from '../src/ssr/createRequestHandler' import { @@ -41,10 +41,6 @@ function deferred() { return { promise, resolve } } -afterEach(() => { - vi.restoreAllMocks() -}) - describe('serverSsr.cleanup', () => { test('onCleanup listeners run exactly once', () => { const router = buildRouter() @@ -117,6 +113,9 @@ describe('serverSsr.cleanup', () => { const value = deferred() const router = buildRouter({ value: value.promise }) attachRouterServerSsrUtils({ router, manifest: undefined }) + onTestFinished(() => { + router.serverSsr?.cleanup() + }) await router.load() await router.serverSsr!.dehydrate() @@ -136,14 +135,15 @@ describe('serverSsr.cleanup', () => { expect(renderFinishedCalls).toBe(0) router.serverSsr!.setRenderFinished() expect(renderFinishedCalls).toBe(1) - - router.serverSsr?.cleanup() }) test('render-finished listeners can synchronously finish serialization', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) attachRouterServerSsrUtils({ router, manifest: undefined }) + onTestFinished(() => { + router.serverSsr?.cleanup() + }) await router.load() await router.serverSsr!.dehydrate() @@ -160,13 +160,14 @@ describe('serverSsr.cleanup', () => { await serializationDone expect(router.serverSsr!.takeBufferedHtml()).toContain('$_TSR.e()') - - router.serverSsr?.cleanup() }) test('late serialization listener runs safely and returns unsubscribe', async () => { const router = buildRouter() attachRouterServerSsrUtils({ router, manifest: undefined }) + onTestFinished(() => { + router.serverSsr?.cleanup() + }) await router.load() await router.serverSsr!.dehydrate() @@ -178,12 +179,14 @@ describe('serverSsr.cleanup', () => { expect(calls).toBe(1) expect(() => unsubscribe()).not.toThrow() - router.serverSsr?.cleanup() }) test('stream fast path only reserves when no SSR work is pending', async () => { const router = buildRouter() attachRouterServerSsrUtils({ router, manifest: undefined }) + onTestFinished(() => { + router.serverSsr?.cleanup() + }) await router.load() await router.serverSsr!.dehydrate() @@ -193,14 +196,15 @@ describe('serverSsr.cleanup', () => { router.serverSsr!.setRenderFinished() expect(router.serverSsr!.reserveStreamFastPath()).toBe(true) expect(router.serverSsr!.reserveStreamFastPath()).toBe(false) - - router.serverSsr?.cleanup() }) test('stream fast path rejects while SSR work is pending', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) attachRouterServerSsrUtils({ router, manifest: undefined }) + onTestFinished(() => { + router.serverSsr?.cleanup() + }) await router.load() await router.serverSsr!.dehydrate() @@ -220,32 +224,31 @@ describe('serverSsr.cleanup', () => { router.serverSsr!.setRenderFinished() expect(router.serverSsr!.reserveStreamFastPath()).toBe(false) expect(router.serverSsr!.takeBufferedHtml()).toContain(' { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + errorSpy.mockRestore() + }) const router = buildRouter() - try { - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + onTestFinished(() => { + router.serverSsr?.cleanup() + }) - const calls: Array = [] - router.serverSsr!.onInjectedHtml(() => { - calls.push('a') - throw new Error('boom') - }) - router.serverSsr!.onInjectedHtml(() => { - calls.push('b') - }) + const calls: Array = [] + router.serverSsr!.onInjectedHtml(() => { + calls.push('a') + throw new Error('boom') + }) + router.serverSsr!.onInjectedHtml(() => { + calls.push('b') + }) - router.serverSsr!.injectHtml('') + router.serverSsr!.injectHtml('') - expect(calls).toEqual(['a', 'b']) - } finally { - router.serverSsr?.cleanup() - errorSpy.mockRestore() - } + expect(calls).toEqual(['a', 'b']) }) test('server SSR attach lifecycle runs listeners at attach time', () => { @@ -543,6 +546,9 @@ describe('serverSsr.cleanup', () => { const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => undefined) + onTestFinished(() => { + consoleError.mockRestore() + }) const requestController = new AbortController() bindSsrResponseToRequest( diff --git a/packages/router-core/tests/stay-match-abort.test.ts b/packages/router-core/tests/stay-match-abort.test.ts index a389d2be76..7aebe48b7a 100644 --- a/packages/router-core/tests/stay-match-abort.test.ts +++ b/packages/router-core/tests/stay-match-abort.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -8,10 +8,6 @@ import { } from '../src' import { createTestRouter } from './routerTestUtils' -afterEach(() => { - vi.restoreAllMocks() -}) - /** * A settled success stay-match must keep its abortController un-aborted * across navigations and invalidations: loaders can hand that signal to @@ -211,7 +207,10 @@ describe('stay-match abort scope', () => { }) test('decorative background asset failure still transfers loader signal ownership', async () => { - vi.spyOn(console, 'error').mockImplementation(() => undefined) + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined) + onTestFinished(() => { + log.mockRestore() + }) let loaderCalls = 0 const loaderSignals: Array = [] diff --git a/packages/router-core/tests/transformStreamWithRouter.test.ts b/packages/router-core/tests/transformStreamWithRouter.test.ts index 903efeb0c9..2f7b13858d 100644 --- a/packages/router-core/tests/transformStreamWithRouter.test.ts +++ b/packages/router-core/tests/transformStreamWithRouter.test.ts @@ -6,7 +6,7 @@ // assertions live in transformStreamBackpressure.perf.test.ts. import { ReadableStream } from 'node:stream/web' import { PassThrough } from 'node:stream' -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from '../src/ssr/constants' @@ -649,58 +649,53 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { test('SSR fast path errors on unexpected late injection', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, injectHtml, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - }) - const upstream = makeManualUpstream() + onTestFinished(() => { + errorSpy.mockRestore() + }) + const { router, injectHtml, cleanupCalls } = makeRouter({ + isSerializationFinished: () => true, + reserveStreamFastPath: () => true, + }) + const upstream = makeManualUpstream() - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) - injectHtml('') + const out = transformStreamWithRouter(router as any, upstream.stream as any) + injectHtml('') - await expect(readAll(out as any)).rejects.toThrow( - 'SSR router HTML injected during fast path', - ) - expect(cleanupCalls.count).toBe(1) - } finally { - errorSpy.mockRestore() - } + await expect(readAll(out as any)).rejects.toThrow( + 'SSR router HTML injected during fast path', + ) + expect(cleanupCalls.count).toBe(1) }) test('lifetime timeout cancels upstream and runs cleanup once', async () => { vi.useFakeTimers() - try { - const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - takeBufferedHtml: () => undefined, - }) - const upstream = makeManualUpstream() + onTestFinished(() => { + vi.useRealTimers() + }) + const { router, cleanupCalls } = makeRouter({ + isSerializationFinished: () => true, + takeBufferedHtml: () => undefined, + }) + const upstream = makeManualUpstream() - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - { lifetimeMs: 10 }, - ) + const out = transformStreamWithRouter( + router as any, + upstream.stream as any, + { lifetimeMs: 10 }, + ) - // Do NOT consume. Advance fake time past lifetimeMs deterministically. - await vi.advanceTimersByTimeAsync(15) + // Do NOT consume. Advance fake time past lifetimeMs deterministically. + await vi.advanceTimersByTimeAsync(15) - expect(upstream.cancelled.value).toBe(true) - expect(cleanupCalls.count).toBe(1) + expect(upstream.cancelled.value).toBe(true) + expect(cleanupCalls.count).toBe(1) - // Drain (read errors silently) so vitest doesn't see an unhandled error. - const reader = ( - out as any - ).getReader() as ReadableStreamDefaultReader - reader.read().catch(() => {}) - reader.releaseLock() - } finally { - vi.useRealTimers() - } + // Drain (read errors silently) so vitest doesn't see an unhandled error. + const reader = ( + out as any + ).getReader() as ReadableStreamDefaultReader + reader.read().catch(() => {}) + reader.releaseLock() }) test('upstream cancel() that rejects does not produce an unhandled rejection', async () => { @@ -711,117 +706,104 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { unhandled.push(e?.reason ?? e) } process.on('unhandledRejection', onUnhandled) - try { - const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - takeBufferedHtml: () => undefined, - }) + onTestFinished(() => { + process.off('unhandledRejection', onUnhandled) + }) + const { router, cleanupCalls } = makeRouter({ + isSerializationFinished: () => true, + takeBufferedHtml: () => undefined, + }) - const stream = new ReadableStream({ - start() {}, - cancel() { - // Simulate a misbehaving upstream whose cancel rejects. - throw new Error('boom-cancel') - }, - }) + const stream = new ReadableStream({ + start() {}, + cancel() { + // Simulate a misbehaving upstream whose cancel rejects. + throw new Error('boom-cancel') + }, + }) - const out = transformStreamWithRouter(router as any, stream as any) - const reader = ( - out as any - ).getReader() as ReadableStreamDefaultReader + const out = transformStreamWithRouter(router as any, stream as any) + const reader = ( + out as any + ).getReader() as ReadableStreamDefaultReader - // Consumer goes away → triggers cancelUpstream → upstream cancel throws. - await reader.cancel('consumer-gone').catch(() => {}) - // Allow microtasks for any rejection to surface. - await flush(10) + // Consumer goes away → triggers cancelUpstream → upstream cancel throws. + await reader.cancel('consumer-gone').catch(() => {}) + // Allow microtasks for any rejection to surface. + await flush(10) - expect(cleanupCalls.count).toBe(1) - expect( - unhandled.find( - (e: any) => e && String(e.message || e).includes('boom-cancel'), - ), - ).toBeUndefined() - } finally { - process.off('unhandledRejection', onUnhandled) - } + expect(cleanupCalls.count).toBe(1) + expect( + unhandled.find( + (e: any) => e && String(e.message || e).includes('boom-cancel'), + ), + ).toBeUndefined() }) test('server cleanup throwing does not prevent terminal stream cleanup', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, finishSerialization } = makeRouter({ - cleanup: () => { - throw new Error('cleanup-boom') - }, - }) - const upstream = makeManualUpstream() + onTestFinished(() => { + errorSpy.mockRestore() + }) + const { router, finishSerialization } = makeRouter({ + cleanup: () => { + throw new Error('cleanup-boom') + }, + }) + const upstream = makeManualUpstream() - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) - upstream.push('done') - upstream.close() - finishSerialization() + const out = transformStreamWithRouter(router as any, upstream.stream as any) + upstream.push('done') + upstream.close() + finishSerialization() - await expect(readAll(out as any)).resolves.toContain('done') - expect(errorSpy).toHaveBeenCalled() - } finally { - errorSpy.mockRestore() - } + await expect(readAll(out as any)).resolves.toContain('done') + expect(errorSpy).toHaveBeenCalled() }) test('throwing injected listener does not skip transform drain', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, injectHtml, finishSerialization } = makeRouter() - router.serverSsr!.onInjectedHtml(() => { - throw new Error('external-listener-boom') - }) - const upstream = makeManualUpstream() + onTestFinished(() => { + errorSpy.mockRestore() + }) + const { router, injectHtml, finishSerialization } = makeRouter() + router.serverSsr!.onInjectedHtml(() => { + throw new Error('external-listener-boom') + }) + const upstream = makeManualUpstream() - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) + const out = transformStreamWithRouter(router as any, upstream.stream as any) - upstream.push('
app
') - injectHtml('') - upstream.push('') - upstream.close() - finishSerialization() + upstream.push('
app
') + injectHtml('') + upstream.push('') + upstream.close() + finishSerialization() - const text = await readAll(out as any) - expect(text).toContain('') - expect(text.indexOf('')).toBeLessThan( - text.indexOf(''), - ) - } finally { - errorSpy.mockRestore() - } + const text = await readAll(out as any) + expect(text).toContain('') + expect(text.indexOf('')).toBeLessThan( + text.indexOf(''), + ) }) test('tail overflow errors and runs cleanup', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, cleanupCalls, finishSerialization } = makeRouter() - const upstream = makeManualUpstream() + onTestFinished(() => { + errorSpy.mockRestore() + }) + const { router, cleanupCalls, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) - upstream.push(`done${'x'.repeat(64 * 1024 + 1)}`) - upstream.close() - finishSerialization() + const out = transformStreamWithRouter(router as any, upstream.stream as any) + upstream.push(`done${'x'.repeat(64 * 1024 + 1)}`) + upstream.close() + finishSerialization() - await expect(readAll(out as any)).rejects.toThrow( - 'SSR stream tail exceeded maximum buffer', - ) - expect(cleanupCalls.count).toBe(1) - } finally { - errorSpy.mockRestore() - } + await expect(readAll(out as any)).rejects.toThrow( + 'SSR stream tail exceeded maximum buffer', + ) + expect(cleanupCalls.count).toBe(1) }) test('router HTML overflow errors and runs cleanup', async () => { @@ -839,25 +821,21 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { test('pending output overflow errors and runs cleanup', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, cleanupCalls, finishSerialization } = makeRouter() - const upstream = makeManualUpstream() + onTestFinished(() => { + errorSpy.mockRestore() + }) + const { router, cleanupCalls, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) - upstream.push(`${'x'.repeat(16 * 1024 * 1024 + 1)}`) - upstream.close() - finishSerialization() + const out = transformStreamWithRouter(router as any, upstream.stream as any) + upstream.push(`${'x'.repeat(16 * 1024 * 1024 + 1)}`) + upstream.close() + finishSerialization() - await expect(readAll(out as any)).rejects.toThrow( - 'SSR stream pending output exceeded maximum buffer', - ) - expect(cleanupCalls.count).toBe(1) - } finally { - errorSpy.mockRestore() - } + await expect(readAll(out as any)).rejects.toThrow( + 'SSR stream pending output exceeded maximum buffer', + ) + expect(cleanupCalls.count).toBe(1) }) test('long text without closing tags partially flushes and keeps bounded leftover', async () => { @@ -1032,6 +1010,11 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { finishSerialization() const pass = new PassThrough() + onTestFinished(() => { + if (!pass.destroyed) { + pass.destroy() + } + }) let aborts = 0 const out = transformPipeableStreamWithRouter(router as any, pass, { onAbort: () => aborts++, @@ -1051,83 +1034,86 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { await Promise.resolve() expect(aborts).toBe(1) - - // Cleanup: destroy upstream so we don't leak. - if (!pass.destroyed) pass.destroy() }) test('onAbort: lifetime timeout triggers abort exactly once', async () => { vi.useFakeTimers() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + vi.useRealTimers() + }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const { router, finishSerialization } = makeRouter() - finishSerialization() - - // Upstream that never produces or closes. - const upstream = new ReadableStream({ - pull() { - // never enqueue, never close - }, - }) + onTestFinished(() => { + warnSpy.mockRestore() + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + errorSpy.mockRestore() + }) + const { router, finishSerialization } = makeRouter() + finishSerialization() - let aborts = 0 + // Upstream that never produces or closes. + const upstream = new ReadableStream({ + pull() { + // never enqueue, never close + }, + }) - const out = transformReadableStreamWithRouter( - router as any, - upstream as any, - { onAbort: () => aborts++, lifetimeMs: 1000 }, - ) + let aborts = 0 - // Start reading (which may reject when stream is errored) - const reader = (out as any).getReader() - const readP = reader.read().catch(() => undefined) + const out = transformReadableStreamWithRouter( + router as any, + upstream as any, + { onAbort: () => aborts++, lifetimeMs: 1000 }, + ) - await vi.advanceTimersByTimeAsync(1500) - await readP + // Start reading (which may reject when stream is errored) + const reader = (out as any).getReader() + const readP = reader.read().catch(() => undefined) - expect(aborts).toBe(1) - } finally { - errorSpy.mockRestore() - warnSpy.mockRestore() - vi.useRealTimers() - } + await vi.advanceTimersByTimeAsync(1500) + await readP + + expect(aborts).toBe(1) }) test('default lifetime is derived from timeoutMs', async () => { vi.useFakeTimers() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + vi.useRealTimers() + }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const { router, finishSerialization } = makeRouter() - finishSerialization() - const upstream = new ReadableStream({ - pull() { - // never enqueue, never close - }, - }) + onTestFinished(() => { + warnSpy.mockRestore() + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => { + errorSpy.mockRestore() + }) + const { router, finishSerialization } = makeRouter() + finishSerialization() + const upstream = new ReadableStream({ + pull() { + // never enqueue, never close + }, + }) - let aborts = 0 - const out = transformReadableStreamWithRouter( - router as any, - upstream as any, - { onAbort: () => aborts++, timeoutMs: 10 }, - ) + let aborts = 0 + const out = transformReadableStreamWithRouter( + router as any, + upstream as any, + { onAbort: () => aborts++, timeoutMs: 10 }, + ) - const reader = (out as any).getReader() - const readP = reader.read().catch(() => undefined) + const reader = (out as any).getReader() + const readP = reader.read().catch(() => undefined) - await vi.advanceTimersByTimeAsync(15) - expect(aborts).toBe(0) + await vi.advanceTimersByTimeAsync(15) + expect(aborts).toBe(0) - await vi.advanceTimersByTimeAsync(6) - await readP - expect(aborts).toBe(1) - } finally { - errorSpy.mockRestore() - warnSpy.mockRestore() - vi.useRealTimers() - } + await vi.advanceTimersByTimeAsync(6) + await readP + expect(aborts).toBe(1) }) test('upstream writable abort surfaces; readable does not hang', async () => { @@ -1140,28 +1126,27 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { // resolve (with done or an error) rather than wait for lifetimeMs. const ts = new TransformStream() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - let aborts = 0 - const out = transformReadableStreamWithRouter( - router as any, - ts.readable as any, - { onAbort: () => aborts++ }, - ) - - void ts.writable.abort(new Error('setup-throw')).catch(() => {}) + onTestFinished(() => { + errorSpy.mockRestore() + }) + let aborts = 0 + const out = transformReadableStreamWithRouter( + router as any, + ts.readable as any, + { onAbort: () => aborts++ }, + ) - const reader = (out as any).getReader() - // Either the read resolves with done, or it rejects with the abort - // reason. Both prove non-hang behavior; we just require it terminates. - const terminated = await reader - .read() - .then(() => true) - .catch(() => true) + void ts.writable.abort(new Error('setup-throw')).catch(() => {}) - expect(terminated).toBe(true) - expect(aborts).toBe(1) - } finally { - errorSpy.mockRestore() - } + const reader = (out as any).getReader() + // Either the read resolves with done, or it rejects with the abort + // reason. Both prove non-hang behavior; we just require it terminates. + const terminated = await reader + .read() + .then(() => true) + .catch(() => true) + + expect(terminated).toBe(true) + expect(aborts).toBe(1) }) }) diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 9e5c14c413..c9bbe9adac 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { decodePath, deepEqual, @@ -485,18 +485,15 @@ describe('deepEqual', () => { () => { // @ts-expect-error -- typescript is right to complain here, don't do this! Object.prototype.x = 'x' + onTestFinished(() => { + // @ts-expect-error + delete Object.prototype.x + }) const a = { a: 1 } const b = { a: 1 } expect(deepEqual(a, b, { ignoreUndefined: false })).toEqual(true) }, ) - - afterEach(() => { - // it's probably not necessary to clean this up because vitest isolates tests - // but just in case isolation ever gets disabled, we clean the prototype to avoid disturbing other tests - // @ts-expect-error - delete Object.prototype.x - }) }) }) diff --git a/packages/router-devtools-core/tests/cache-replacement.test.ts b/packages/router-devtools-core/tests/cache-replacement.test.ts index e44e1cce53..a157c81b7d 100644 --- a/packages/router-devtools-core/tests/cache-replacement.test.ts +++ b/packages/router-devtools-core/tests/cache-replacement.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest' import { TanStackRouterDevtoolsPanelCore } from '../src/TanStackRouterDevtoolsPanelCore' import type { AnyRouteMatch, AnyRouter } from '@tanstack/router-core' @@ -17,25 +17,21 @@ function createCachedMatch(loaderData: string): AnyRouteMatch { } describe('cached matches', () => { - let panel: TanStackRouterDevtoolsPanelCore | undefined - // Warm Vite's lazy transform of the panel chunk outside any test so its // cost never counts against a test's timeout budget on slow CI runners. beforeAll(async () => { await import('../src/BaseTanStackRouterDevtoolsPanel') }, 30_000) - afterEach(() => { - panel?.unmount() - panel = undefined - document.body.innerHTML = '' - try { - window.localStorage.clear() - } catch {} - vi.useRealTimers() - }) - it('uses the default gc time and refreshes replaced cache entries', async () => { + onTestFinished(() => { + document.body.innerHTML = '' + try { + window.localStorage.clear() + } catch {} + vi.useRealTimers() + }) + vi.useFakeTimers() const route = { @@ -78,8 +74,15 @@ describe('cached matches', () => { const container = document.createElement('div') document.body.append(container) - panel = new TanStackRouterDevtoolsPanelCore({ router, routerState }) + const panel = new TanStackRouterDevtoolsPanelCore({ router, routerState }) + let mounted = false + onTestFinished(() => { + if (mounted) { + panel.unmount() + } + }) panel.mount(container) + mounted = true await vi.waitFor(() => { expect( diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts index 3e0831fe91..870f1eb6df 100644 --- a/packages/router-generator/tests/generator.test.ts +++ b/packages/router-generator/tests/generator.test.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs' import fs from 'node:fs/promises' import path, { dirname, join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { index, @@ -413,7 +413,10 @@ describe('generator works', async () => { async () => { const folderName = 'only-root' const folderRoot = makeFolderDir(folderName) - let pathCreated = false + const generatedRouteTreeDirectory = join(folderRoot, 'generated') + onTestFinished(() => + fs.rm(generatedRouteTreeDirectory, { recursive: true, force: true }), + ) const config = await setupConfig(folderName) @@ -421,8 +424,7 @@ describe('generator works', async () => { await preprocess(folderName) config.generatedRouteTree = join( - folderRoot, - 'generated', + generatedRouteTreeDirectory, `/routeTree.gen.ts`, ) const generator = new Generator({ config, root: folderRoot }) @@ -448,19 +450,12 @@ describe('generator works', async () => { ), ) - pathCreated = await fs.access(dirname(config.generatedRouteTree)).then( - () => true, - () => false, - ) - - await expect(pathCreated).toBe(true) + await expect( + fs.access(dirname(config.generatedRouteTree)), + ).resolves.toBeUndefined() } await postprocess(folderName) - - if (pathCreated) { - await fs.rm(dirname(config.generatedRouteTree), { recursive: true }) - } }, ) }) diff --git a/packages/router-generator/tests/utils.test.ts b/packages/router-generator/tests/utils.test.ts index 504dacc0e6..660bd4ec10 100644 --- a/packages/router-generator/tests/utils.test.ts +++ b/packages/router-generator/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { RoutePrefixMap, cleanPath, @@ -262,6 +262,7 @@ describe('determineInitialRoutePath', () => { it('errors on disallowed escaped character', () => { const consoleSpy = vi.spyOn(console, 'error') + onTestFinished(() => consoleSpy.mockRestore()) expect(() => determineInitialRoutePath('/a[/]')).toThrowError() @@ -270,8 +271,6 @@ describe('determineInitialRoutePath', () => { 'You cannot use any of the following characters in square brackets: /, \\, ?, #, :, *, <, >, |, !, $, %\n' + 'Please remove and/or replace them.', ) - - consoleSpy.mockRestore() }) it('escapes characters correctly', () => { diff --git a/packages/router-generator/tests/validate-route-params.test.ts b/packages/router-generator/tests/validate-route-params.test.ts index 14b61adc3d..764f9cb204 100644 --- a/packages/router-generator/tests/validate-route-params.test.ts +++ b/packages/router-generator/tests/validate-route-params.test.ts @@ -1,14 +1,12 @@ import { join } from 'node:path' -import { afterAll, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Generator, getConfig } from '../src' describe('validateRouteParams via generator', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - afterAll(() => { - warnSpy.mockRestore() - }) - it('should warn for invalid param names when running the generator', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warnSpy.mockRestore()) + const folderName = 'invalid-param-names' const dir = join(process.cwd(), 'tests', 'generator', folderName) diff --git a/packages/router-plugin/tests/router-plugin-context.test.ts b/packages/router-plugin/tests/router-plugin-context.test.ts index ecb6e7e167..165738c2de 100644 --- a/packages/router-plugin/tests/router-plugin-context.test.ts +++ b/packages/router-plugin/tests/router-plugin-context.test.ts @@ -1,6 +1,6 @@ import path from 'node:path' import * as t from '@babel/types' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { parseAst } from '@tanstack/router-utils' import { createRouterCodeSplitterPlugin } from '../src/core/router-code-splitter-plugin' import { unpluginRouterComposedFactory } from '../src/core/router-composed-plugin' @@ -114,61 +114,57 @@ function component() { if (production) { vi.stubEnv('NODE_ENV', 'production') - } - - try { - const plugins = createRouterCodeSplitterPlugin( - { - target: 'react', - autoCodeSplitting: true, - codeSplittingOptions: production ? undefined : { addHmr: false }, - }, - context, - ) - const referencePlugin = getReferencePlugin(plugins) - const virtualPlugin = getCodeSplitterPlugin(plugins, virtualPluginName) - - await configurePlugin(referencePlugin, production ? 'build' : 'serve') - - const referenceCode = getCode( - await transformReferenceRoute(referencePlugin, routeCode, routeFile), - ) - const virtualCode = getCode( - await transformReferenceRoute( - virtualPlugin, - routeCode, - `${routeFile}?tsr-split=component`, - ), - ) - - expect(referenceCode).not.toContain('TSRFastRefreshAnchor') - expect(virtualCode).toContain('function component()') - expect(virtualCode).toContain('export { component }') - expect(virtualCode).not.toContain('SplitComponent') - } finally { - if (production) { + onTestFinished(() => { vi.unstubAllEnvs() - } + }) } + + const plugins = createRouterCodeSplitterPlugin( + { + target: 'react', + autoCodeSplitting: true, + codeSplittingOptions: production ? undefined : { addHmr: false }, + }, + context, + ) + const referencePlugin = getReferencePlugin(plugins) + const virtualPlugin = getCodeSplitterPlugin(plugins, virtualPluginName) + + await configurePlugin(referencePlugin, production ? 'build' : 'serve') + + const referenceCode = getCode( + await transformReferenceRoute(referencePlugin, routeCode, routeFile), + ) + const virtualCode = getCode( + await transformReferenceRoute( + virtualPlugin, + routeCode, + `${routeFile}?tsr-split=component`, + ), + ) + + expect(referenceCode).not.toContain('TSRFastRefreshAnchor') + expect(virtualCode).toContain('function component()') + expect(virtualCode).toContain('export { component }') + expect(virtualCode).not.toContain('SplitComponent') }, ) it('does not install the standalone route HMR plugin in production', () => { vi.stubEnv('NODE_ENV', 'production') + onTestFinished(() => { + vi.unstubAllEnvs() + }) - try { - const plugins = unpluginRouterComposedFactory( - { target: 'react', autoCodeSplitting: false }, - { framework: 'vite' }, - ) + const plugins = unpluginRouterComposedFactory( + { target: 'react', autoCodeSplitting: false }, + { framework: 'vite' }, + ) - const pluginArray = Array.isArray(plugins) ? plugins : [plugins] - expect( - pluginArray.some((plugin) => plugin.name === 'tanstack-router:hmr'), - ).toBe(false) - } finally { - vi.unstubAllEnvs() - } + const pluginArray = Array.isArray(plugins) ? plugins : [plugins] + expect( + pluginArray.some((plugin) => plugin.name === 'tanstack-router:hmr'), + ).toBe(false) }) it('keeps multiple code-splitter instances isolated by explicit context', async () => { diff --git a/packages/router-ssr-query-core/tests/index.test.ts b/packages/router-ssr-query-core/tests/index.test.ts index a167dbd03e..6241d39291 100644 --- a/packages/router-ssr-query-core/tests/index.test.ts +++ b/packages/router-ssr-query-core/tests/index.test.ts @@ -1,5 +1,5 @@ import { QueryClient } from '@tanstack/query-core' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { setupCoreRouterSsrQueryIntegration } from '../src' type TestRouter = { @@ -116,32 +116,16 @@ function createDehydratedQueryState(data: string) { } } -// Track QueryClients per-test and clear them in afterEach. Without this, -// queries created in tests keep their gcTime setTimeout handles open (5min -// default in jsdom), pinning QueryClient + QueryCache + this test's router -// alive across the whole suite. cancelQueries() + clear() drops them. -const trackedQueryClients = new Set() function track(client: T): T { - trackedQueryClients.add(client) - return client -} - -afterEach(() => { - vi.clearAllMocks() - for (const client of trackedQueryClients) { - try { - client.cancelQueries() - } catch { - // ignore - } + onTestFinished(async () => { try { + await client.cancelQueries() + } finally { client.clear() - } catch { - // ignore } - } - trackedQueryClients.clear() -}) + }) + return client +} describe('setupCoreRouterSsrQueryIntegration', () => { it('uses custom dehydrate options for the initial payload and streamed queries', async () => { @@ -346,23 +330,21 @@ describe.runIf(gcTestsEnabled)('SSR memory: GC reclamation', () => { const qcRef = new WeakRef(queryClient) + onTestFinished(() => { + qcRef.deref()?.clear() + }) + // Drop strong refs WITHOUT triggering cleanup. queryClient = null serverRouter = null - try { - await forceGc() + await forceGc() - // Subscriber closure + gcTime timers keep it alive. This is the bug - // we are guarding against; if this ever passes (returns undefined) the - // production retention chain has changed and the cleanup-based test - // above may also need re-validation. - expect(qcRef.deref()).toBeDefined() - } finally { - // Avoid leaving the retained client + gcTime timers alive for up to - // 5 minutes after the test finishes. - qcRef.deref()?.clear() - } + // Subscriber closure + gcTime timers keep it alive. This is the bug + // we are guarding against; if this ever passes (returns undefined) the + // production retention chain has changed and the cleanup-based test + // above may also need re-validation. + expect(qcRef.deref()).toBeDefined() }) }) diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index 29f6553d56..fe9eaae35a 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -38,17 +38,14 @@ const createTestManifest = ( }, }) satisfies Manifest -const browserHistories: Array> = [] - const createTestBrowserHistory = () => { const history = createBrowserHistory() - browserHistories.push(history) + onTestFinished(() => history.destroy()) return history } afterEach(() => { cleanup() - browserHistories.splice(0).forEach((history) => history.destroy()) window.history.replaceState(null, 'root', '/') delete window.$_TSR }) diff --git a/packages/solid-router/tests/Transitioner.test.tsx b/packages/solid-router/tests/Transitioner.test.tsx index 26fa0da5b4..b5e41a56bc 100644 --- a/packages/solid-router/tests/Transitioner.test.tsx +++ b/packages/solid-router/tests/Transitioner.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { render, waitFor } from '@solidjs/testing-library' import { createMemoryHistory, @@ -29,6 +29,7 @@ describe('Transitioner', () => { // Mock router.load() to verify it gets called const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) render(() => ) @@ -37,7 +38,5 @@ describe('Transitioner', () => { expect(loadSpy).toHaveBeenCalledTimes(1) expect(loader).toHaveBeenCalledTimes(1) }) - - loadSpy.mockRestore() }) }) diff --git a/packages/solid-router/tests/component-preload-retry.test.tsx b/packages/solid-router/tests/component-preload-retry.test.tsx index 0a26f0af26..4cb4132c18 100644 --- a/packages/solid-router/tests/component-preload-retry.test.tsx +++ b/packages/solid-router/tests/component-preload-retry.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' import { RouterProvider, @@ -11,14 +11,13 @@ import { } from '../src' import type { ErrorComponentProps } from '../src' -afterEach(() => { - cleanup() - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) +afterEach(cleanup) test('a successful server component download is reused', async () => { vi.stubGlobal('window', undefined) + onTestFinished(() => { + vi.unstubAllGlobals() + }) const importer = vi.fn().mockResolvedValue({ default: () => null }) const Page = lazyRouteComponent(importer) @@ -42,7 +41,8 @@ test('a component loads when rendered before preload', async () => { }) test('a failed component download is retried from the route error UI', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => consoleError.mockRestore()) const PageContent = () =>
Page content
const importer = vi diff --git a/packages/solid-router/tests/createLazyRoute.test.tsx b/packages/solid-router/tests/createLazyRoute.test.tsx index 4dcbbbfc2c..4ef0359f0b 100644 --- a/packages/solid-router/tests/createLazyRoute.test.tsx +++ b/packages/solid-router/tests/createLazyRoute.test.tsx @@ -202,29 +202,24 @@ it('renders an eager loader error with a delayed lazy errorComponent', async () defaultPendingMinMs: 0, defaultPendingComponent: () =>

Loading default

, }) - let navigation: Promise | undefined - - try { - render(() => ) - expect(await screen.findByText('Index page')).toBeInTheDocument() - - navigation = router.navigate({ to: '/page' }) - expect(await screen.findByText('Loading default')).toBeInTheDocument() - - loader.resolve() - await loaderErrorHandled - lazyOptions.resolve(lazyPageOptions) - await navigation + render(() => ) + expect(await screen.findByText('Index page')).toBeInTheDocument() - expect(await screen.findByRole('alert')).toHaveTextContent( - 'Lazy error: loader failed', - ) - } finally { + const navigation = router.navigate({ to: '/page' }) + onTestFinished(async () => { lazyOptions.resolve(lazyPageOptions) loader.resolve() loaderErrorHandled.resolve() - if (navigation) { - await Promise.allSettled([navigation]) - } - } + await Promise.allSettled([navigation]) + }) + expect(await screen.findByText('Loading default')).toBeInTheDocument() + + loader.resolve() + await loaderErrorHandled + lazyOptions.resolve(lazyPageOptions) + await navigation + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Lazy error: loader failed', + ) }) diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index af45fdccee..e7a2b9b895 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -194,6 +194,10 @@ test('global catch boundary resets when a background child generation recovers', ).toBeInTheDocument() const invalidation = router.invalidate() + onTestFinished(async () => { + refresh.resolve(2) + await Promise.allSettled([invalidation]) + }) await vi.waitFor(() => expect(loaderCalls).toBe(2)) expect(screen.getByText('stale child render failed')).toBeInTheDocument() expect(screen.queryByText(/Recovered child revision/)).not.toBeInTheDocument() diff --git a/packages/solid-router/tests/issue-7986-retained-pending.test.tsx b/packages/solid-router/tests/issue-7986-retained-pending.test.tsx index db0092610c..b5f70144aa 100644 --- a/packages/solid-router/tests/issue-7986-retained-pending.test.tsx +++ b/packages/solid-router/tests/issue-7986-retained-pending.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { Outlet, @@ -12,47 +12,50 @@ import { } from '../src' import type { ControlledPromise } from '@tanstack/router-core' -const controlledPromises = new Set>() -const pendingOperations = new Set>() +function createTestHarness() { + const controlledPromises = new Set>() + const pendingOperations = new Set>() -afterEach(async () => { - try { - for (const promise of controlledPromises) { - if (promise.status === 'pending') { - promise.resolve() + onTestFinished(async () => { + try { + for (const promise of controlledPromises) { + if (promise.status === 'pending') { + promise.resolve() + } } + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(pendingOperations) + } finally { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() } - if (vi.isFakeTimers()) { - await vi.runAllTimersAsync() - } - await Promise.allSettled(pendingOperations) - } finally { - controlledPromises.clear() - pendingOperations.clear() - cleanup() - vi.useRealTimers() - vi.restoreAllMocks() + }) + + function controlled() { + const promise = createControlledPromise() + controlledPromises.add(promise) + return promise } -}) -const navigationDelay = 100 + function track(operation: Promise) { + pendingOperations.add(operation) + return operation + } -function delayNavigation() { - return new Promise((resolve) => setTimeout(resolve, navigationDelay)) + return { controlled, track } } -function controlled() { - const promise = createControlledPromise() - controlledPromises.add(promise) - return promise -} +const navigationDelay = 100 -function track(operation: Promise) { - pendingOperations.add(operation) - return operation +function delayNavigation() { + return new Promise((resolve) => setTimeout(resolve, navigationDelay)) } function setup() { + const { controlled, track } = createTestHarness() const navigationBeforeLoadStarted = controlled() let beforeLoadCalls = 0 @@ -95,11 +98,11 @@ function setup() { defaultPendingMinMs: 1, }) - return { router, navigationBeforeLoadStarted } + return { router, navigationBeforeLoadStarted, track } } test('a search-only navigation retains successful UI while beforeLoad reruns', async () => { - const { router, navigationBeforeLoadStarted } = setup() + const { router, navigationBeforeLoadStarted, track } = setup() render(() => ) expect(await screen.findByTestId('content')).toHaveTextContent( 'project=p1 tab=default', @@ -138,7 +141,7 @@ test('a search-only navigation retains successful UI while beforeLoad reruns', a }) test('a path-param navigation retains successful UI while beforeLoad reruns', async () => { - const { router, navigationBeforeLoadStarted } = setup() + const { router, navigationBeforeLoadStarted, track } = setup() render(() => ) expect(await screen.findByTestId('content')).toHaveTextContent( 'project=p1 tab=default', @@ -176,6 +179,7 @@ test('a path-param navigation retains successful UI while beforeLoad reruns', as }) test('a blocking reload retains the exact successful match', async () => { + const { controlled, track } = createTestHarness() const reloadStarted = controlled() let loaderCalls = 0 @@ -243,6 +247,7 @@ test('a blocking reload retains the exact successful match', async () => { }) test('a cached success retries through pending UI when an error is mounted', async () => { + const { controlled, track } = createTestHarness() vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {}) const retryStarted = controlled() @@ -303,6 +308,7 @@ test('a cached success retries through pending UI when an error is mounted', asy }) test('a cache-only success retries through pending UI over mounted success', async () => { + const { controlled, track } = createTestHarness() const retryStarted = controlled() const retry = controlled() let loaderCalls = 0 @@ -356,6 +362,7 @@ test('a cache-only success retries through pending UI over mounted success', asy }) test('a success hidden below an error boundary retries through pending UI', async () => { + const { controlled, track } = createTestHarness() vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {}) const childReloadStarted = controlled() @@ -429,6 +436,7 @@ test('a success hidden below an error boundary retries through pending UI', asyn }) test('a global not-found destination does not retain the mounted root success', async () => { + const { controlled, track } = createTestHarness() const missingStarted = controlled() const missingLoader = controlled() let loaderCalls = 0 @@ -478,6 +486,7 @@ test('a global not-found destination does not retain the mounted root success', }) test('lazy fuzzy-boundary relocation retains the mounted parent', async () => { + const { controlled, track } = createTestHarness() const lazyStarted = controlled() const lazyRoute = controlled() const parentReloadStarted = controlled() @@ -560,6 +569,7 @@ test('lazy fuzzy-boundary relocation retains the mounted parent', async () => { }) test('a superseding navigation replaces an unrelated pending presentation', async () => { + const { controlled, track } = createTestHarness() const otherStarted = controlled() const otherLoader = controlled() const pageReloadStarted = controlled() diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 7642030465..363feab17d 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -1,5 +1,14 @@ import * as Solid from 'solid-js' -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + test, + vi, +} from 'vitest' import { cleanup, fireEvent, @@ -2426,6 +2435,7 @@ describe('Link', () => { const homeLink = await screen.findByTestId('home-link') const consoleWarnSpy = vi.spyOn(console, 'warn') + onTestFinished(() => consoleWarnSpy.mockRestore()) fireEvent.click(homeLink) @@ -2435,8 +2445,6 @@ describe('Link', () => { expect(homeHeading).toBeInTheDocument() expect(consoleWarnSpy).not.toHaveBeenCalled() - - consoleWarnSpy.mockRestore() }) test('when navigating from /posts to ../posts/$postId', async () => { @@ -2984,6 +2992,7 @@ describe('Link', () => { expect(post1Heading).toBeInTheDocument() const consoleWarnSpy = vi.spyOn(console, 'warn') + onTestFinished(() => consoleWarnSpy.mockRestore()) const usersLink = await screen.findByTestId('users-link') fireEvent.click(usersLink) @@ -3001,8 +3010,6 @@ describe('Link', () => { expect(user1Heading).toBeInTheDocument() expect(consoleWarnSpy).not.toHaveBeenCalled() - - consoleWarnSpy.mockRestore() }) test('when navigating from /posts/$postId to ./info and the current route is /posts/$postId/details', async () => { @@ -5514,6 +5521,9 @@ describe('createLink', () => { const originalOpen = window.open const openMock = vi.fn() window.open = openMock + onTestFinished(() => { + window.open = originalOpen + }) render(() => ) @@ -5531,8 +5541,6 @@ describe('createLink', () => { }) await expect(screen.findByTestId('posts-heading')).rejects.toThrow() - - window.open = originalOpen }) it('should allow override of target prop even when custom component sets it', async () => { diff --git a/packages/solid-router/tests/loaders.test.tsx b/packages/solid-router/tests/loaders.test.tsx index 63ac8e3e2e..3467b20b79 100644 --- a/packages/solid-router/tests/loaders.test.tsx +++ b/packages/solid-router/tests/loaders.test.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { z } from 'zod' import { @@ -350,12 +350,19 @@ test('#7673: a spontaneous loader AbortError renders the boundary without execut const routeTree = rootRoute.addChildren([indexRoute]) const router = createRouter({ routeTree, basepath: '/app' }) + let unsubscribe: (() => void) | undefined + const unsubscribeOnce = () => { + const dispose = unsubscribe + unsubscribe = undefined + dispose?.() + } const rendered = new Promise((resolve) => { - const unsubscribe = router.subscribe('onRendered', () => { - unsubscribe() + unsubscribe = router.subscribe('onRendered', () => { + unsubscribeOnce() resolve() }) }) + onTestFinished(unsubscribeOnce) render(() => ) diff --git a/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx index 1b1ac2268c..ea7cdc8fb7 100644 --- a/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx +++ b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen } from '@solidjs/testing-library' -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { Outlet, @@ -10,21 +10,29 @@ import { createRouter, } from '../src' import type { AnyRouter } from '../src' - -const testCleanups: Array<() => void | Promise> = [] - -afterEach(async () => { - const pendingCleanups = testCleanups - .splice(0) - .reverse() - .map((testCleanup) => testCleanup()) - if (vi.isFakeTimers()) { - await vi.runAllTimersAsync() - } - await Promise.allSettled(pendingCleanups) - cleanup() - vi.useRealTimers() -}) +import type { ControlledPromise } from '@tanstack/router-core' + +function setupTestCleanup( + gates: ReadonlyArray>, + pendingOperations: ReadonlyArray>, +) { + onTestFinished(async () => { + try { + for (const gate of gates) { + if (gate.status === 'pending') { + gate.resolve() + } + } + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(pendingOperations) + } finally { + cleanup() + vi.useRealTimers() + } + }) +} test.each(['child', 'root'] as const)( 'a mounted %s pending fallback follows an overlapping load generation', @@ -32,6 +40,8 @@ test.each(['child', 'root'] as const)( const firstReload = createControlledPromise() const secondReload = createControlledPromise() const reloads = [firstReload, secondReload] + const invalidations: Array> = [] + setupTestCleanup(reloads, invalidations) let loaderCall = 0 const routeOptions = { @@ -76,12 +86,7 @@ test.each(['child', 'root'] as const)( vi.useFakeTimers() const firstInvalidation = router.invalidate({ forcePending: true }) - const invalidations = [firstInvalidation] - testCleanups.push(async () => { - firstReload.resolve() - secondReload.resolve() - await Promise.allSettled(invalidations) - }) + invalidations.push(firstInvalidation) await vi.advanceTimersByTimeAsync(0) expect(loaderCall).toBe(2) expect(screen.getByTestId('pending')).toBeInTheDocument() @@ -125,6 +130,8 @@ test.each(['child', 'root'] as const)( test('forcePending honors pendingMinMs when the reload settles before pendingMs', async () => { const reload = createControlledPromise() + const invalidations: Array> = [] + setupTestCleanup([reload], invalidations) let loaderCall = 0 const rootRoute = createRootRoute({ component: () => }) @@ -151,10 +158,7 @@ test('forcePending honors pendingMinMs when the reload settles before pendingMs' vi.useFakeTimers() const invalidation = router.invalidate({ forcePending: true }) - testCleanups.push(async () => { - reload.resolve() - await Promise.allSettled([invalidation]) - }) + invalidations.push(invalidation) await vi.advanceTimersByTimeAsync(0) expect(screen.getByTestId('fast-pending')).toBeInTheDocument() diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 2192f4f962..4a16c72a4d 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -1,6 +1,14 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + onTestFinished, + test, + vi, +} from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { @@ -142,13 +150,13 @@ describe('redirect', () => { }) render(() => ) - - try { - expect(await screen.findByTestId('pending')).toBeInTheDocument() - expect(screen.queryByTestId('index-page')).not.toBeInTheDocument() - } finally { + onTestFinished(() => { beforeLoad.resolve() - } + }) + + expect(await screen.findByTestId('pending')).toBeInTheDocument() + expect(screen.queryByTestId('index-page')).not.toBeInTheDocument() + beforeLoad.resolve() // The lazy target route adds the async boundary that exposes the stale // redirected-match render path this regression is guarding. diff --git a/packages/solid-router/tests/renderRouterToStream.test.tsx b/packages/solid-router/tests/renderRouterToStream.test.tsx index 6351d83ebd..7907653bd8 100644 --- a/packages/solid-router/tests/renderRouterToStream.test.tsx +++ b/packages/solid-router/tests/renderRouterToStream.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' import { createMemoryHistory, createRootRoute, createRouter } from '../src' import type * as SolidWeb from 'solid-js/web' @@ -77,40 +77,40 @@ describe('renderRouterToStream - bot abort', () => { const router = await buildRouter() const abortController = new AbortController() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const responsePromise = renderRouterToStream({ - request: new Request('http://localhost/', { - headers: { 'User-Agent': 'Googlebot' }, - signal: abortController.signal, - }), - router, - responseHeaders: new Headers(), - children: () => null, - }) - - await Promise.resolve() - abortController.abort(new Error('client-gone')) - - const result = await Promise.race([ - responsePromise, - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - - expect(result).not.toBe(false) - expect(solidMocks.pipeTo).not.toHaveBeenCalled() - const response = unwrapResponse(result as Exclude) - expect(response.body).not.toBeNull() - - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - - expect(terminated).toBe(true) - } finally { + onTestFinished(() => { errorSpy.mockRestore() router.serverSsr?.cleanup() - } + }) + + const responsePromise = renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + children: () => null, + }) + + await Promise.resolve() + abortController.abort(new Error('client-gone')) + + const result = await Promise.race([ + responsePromise, + new Promise((resolve) => setTimeout(() => resolve(false), 2000)), + ]) + + expect(result).not.toBe(false) + expect(solidMocks.pipeTo).not.toHaveBeenCalled() + const response = unwrapResponse(result as Exclude) + expect(response.body).not.toBeNull() + + const terminated = await Promise.race([ + drainBody(response), + new Promise((resolve) => setTimeout(() => resolve(false), 2000)), + ]) + + expect(terminated).toBe(true) }) test('pipeTo rejection aborts writer and terminates response stream', async () => { @@ -123,26 +123,26 @@ describe('renderRouterToStream - bot abort', () => { const router = await buildRouter() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/'), - router, - responseHeaders: new Headers(), - children: () => null, - }), - ) - - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - - expect(terminated).toBe(true) - expect(errorSpy).toHaveBeenCalled() - } finally { + onTestFinished(() => { errorSpy.mockRestore() router.serverSsr?.cleanup() - } + }) + + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: () => null, + }), + ) + + const terminated = await Promise.race([ + drainBody(response), + new Promise((resolve) => setTimeout(() => resolve(false), 2000)), + ]) + + expect(terminated).toBe(true) + expect(errorSpy).toHaveBeenCalled() }) }) diff --git a/packages/solid-router/tests/router-client-stream-cleanup.test.tsx b/packages/solid-router/tests/router-client-stream-cleanup.test.tsx index 0ea9e57077..d8a2b349fa 100644 --- a/packages/solid-router/tests/router-client-stream-cleanup.test.tsx +++ b/packages/solid-router/tests/router-client-stream-cleanup.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { ErrorBoundary } from 'solid-js' import { RouterClient } from '../src/ssr/RouterClient' import { createMemoryHistory, createRootRoute, createRouter } from '../src' @@ -8,15 +8,16 @@ const hydrate = vi.hoisted(() => vi.fn()) vi.mock('@tanstack/router-core/ssr/client', () => ({ hydrate })) -afterEach(() => { - cleanup() - delete window.$_TSR - hydrate.mockReset() -}) +afterEach(cleanup) test.runIf(typeof window !== 'undefined')( 'RouterClient signals streaming cleanup without hiding a hydration failure', async () => { + onTestFinished(() => { + delete window.$_TSR + hydrate.mockReset() + }) + const error = new Error('hydration failed') hydrate.mockRejectedValue(error) const rootRoute = createRootRoute({ component: () =>
Ready
}) diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index ac0b2d145d..53aac35f51 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + vi, +} from 'vitest' import { cleanup, fireEvent, @@ -815,11 +823,11 @@ describe('router emits events during rendering', () => { }) const unsub = router.subscribe('onResolved', mockFn1) + onTestFinished(unsub) await router.load() render(() => ) await waitFor(() => expect(mockFn1).toBeCalled()) - unsub() }) it('after a navigation, should have emitted the "onResolved" event twice', async () => { @@ -828,13 +836,13 @@ describe('router emits events during rendering', () => { }) const unsub = router.subscribe('onResolved', mockFn1) + onTestFinished(unsub) await router.load() await render(() => ) await sleep(0) await router.navigate({ to: '/$', params: { _splat: 'tanner' } }) await waitFor(() => expect(mockFn1).toBeCalledTimes(2)) - unsub() }) it('should emit the "onRendered" event when a route renders, after navigation, and after param/search updates', async () => { @@ -845,6 +853,7 @@ describe('router emits events during rendering', () => { const mockOnRendered = vi.fn() const unsub = router.subscribe('onRendered', mockOnRendered) + onTestFinished(unsub) await router.load() await waitFor(() => expect(mockOnRendered).toBeCalledTimes(0)) @@ -877,8 +886,6 @@ describe('router emits events during rendering', () => { expect(mockOnRendered.mock.calls[3]?.[0]?.toLocation.search.root).toBe( 'search-change', ) - - unsub() }) it('during initial load, should emit the "onBeforeRouteMount" and "onResolved" events in the correct order', async () => { @@ -895,6 +902,8 @@ describe('router emits events during rendering', () => { mockOnBeforeRouteMount, ) const unsubResolved = router.subscribe('onResolved', mockOnResolved) + onTestFinished(unsubBeforeRouteMount) + onTestFinished(unsubResolved) await router.load() render(() => ) @@ -915,9 +924,6 @@ describe('router emits events during rendering', () => { } else { throw new Error('onBeforeRouteMount should be emitted before onResolved.') } - - unsubBeforeRouteMount() - unsubResolved() }) }) @@ -1540,6 +1546,7 @@ describe('history: History gives correct notifcations and state', () => { const unsub = router.history.subscribe(({ action }) => { results.push(action) }) + onTestFinished(unsub) const postsButton = await screen.findByRole('button', { name: 'Posts' }) @@ -1560,8 +1567,6 @@ describe('history: History gives correct notifcations and state', () => { expect(window.location.pathname).toBe('/') expect(results).toEqual([{ type: 'PUSH' }, { type: 'BACK' }]) - - unsub() }) it('should work more complex scenario', async () => { @@ -1578,6 +1583,7 @@ describe('history: History gives correct notifcations and state', () => { const unsub = router.history.subscribe(({ action }) => { results.push(action) }) + onTestFinished(unsub) const replaceButton = await screen.findByRole('button', { name: 'Replace' }) @@ -1632,8 +1638,6 @@ describe('history: History gives correct notifcations and state', () => { { type: 'BACK' }, { type: 'GO', index: 1 }, ]) - - unsub() }) }) diff --git a/packages/solid-router/tests/same-route-pending-blank.test.tsx b/packages/solid-router/tests/same-route-pending-blank.test.tsx index c65e78fc6e..ff97769a18 100644 --- a/packages/solid-router/tests/same-route-pending-blank.test.tsx +++ b/packages/solid-router/tests/same-route-pending-blank.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen } from '@solidjs/testing-library' -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { Outlet, @@ -20,25 +20,27 @@ import { * elapses, the previously committed page 1 content should remain visible. */ -let resolvePendingPage2: (() => void) | undefined -let pendingNavigation: Promise | undefined - -afterEach(async () => { - resolvePendingPage2?.() - if (pendingNavigation) { - await Promise.allSettled([pendingNavigation]) - } - resolvePendingPage2 = undefined - pendingNavigation = undefined - cleanup() - vi.useRealTimers() -}) - test('same-route pending replacement without fallback keeps stale content until pendingMs', async () => { const pendingMs = 100 const page2Gate = createControlledPromise() const page2Started = createControlledPromise() - resolvePendingPage2 = page2Gate.resolve + let pendingNavigation: Promise | undefined + onTestFinished(async () => { + try { + if (page2Gate.status === 'pending') { + page2Gate.resolve() + } + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + if (pendingNavigation) { + await Promise.allSettled([pendingNavigation]) + } + } finally { + cleanup() + vi.useRealTimers() + } + }) const history = createMemoryHistory({ initialEntries: ['/posts?page=1'] }) const root = createRootRoute({ component: () => , diff --git a/packages/solid-router/tests/server/Transitioner.test.tsx b/packages/solid-router/tests/server/Transitioner.test.tsx index 7afdd57058..18026f2889 100644 --- a/packages/solid-router/tests/server/Transitioner.test.tsx +++ b/packages/solid-router/tests/server/Transitioner.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { renderToStringAsync } from 'solid-js/web' import { createMemoryHistory, @@ -30,6 +30,7 @@ describe('Transitioner (server)', () => { // Mock router.load() to verify it gets called const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) await router.load() @@ -37,7 +38,5 @@ describe('Transitioner (server)', () => { expect(loadSpy).toHaveBeenCalledTimes(1) expect(loader).toHaveBeenCalledTimes(1) - - loadSpy.mockRestore() }) }) diff --git a/packages/solid-router/tests/transitioner-remount.test.tsx b/packages/solid-router/tests/transitioner-remount.test.tsx index 3c1bb37025..79ae2b076f 100644 --- a/packages/solid-router/tests/transitioner-remount.test.tsx +++ b/packages/solid-router/tests/transitioner-remount.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' import { Outlet, @@ -39,6 +39,7 @@ describe('Transitioner remount', () => { it('does not load after the provider unmounts', async () => { const { history, router } = setup() const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) const first = render(() => ) expect(await screen.findByText('Index')).toBeTruthy() @@ -56,8 +57,6 @@ describe('Transitioner remount', () => { expect(router.history.location.pathname).toBe('/next') // ...but the router never processed it, so its committed state stayed put. expect(router.state.location.pathname).toBe('/') - - loadSpy.mockRestore() }) // Remounting the same router instance must re-establish the subscription so @@ -67,6 +66,7 @@ describe('Transitioner remount', () => { // Spy before the first mount so the subscription captures the spy by // reference — both the first and second mounts subscribe with it. const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) const first = render(() => ) expect(await screen.findByText('Index')).toBeTruthy() @@ -85,7 +85,5 @@ describe('Transitioner remount', () => { expect(await screen.findByText('Next')).toBeTruthy() expect(router.state.location.pathname).toBe('/next') await waitFor(() => expect(loadSpy).toHaveBeenCalledTimes(1)) - - loadSpy.mockRestore() }) }) diff --git a/packages/solid-router/tests/useMatch.test.tsx b/packages/solid-router/tests/useMatch.test.tsx index 42771fae9a..3b7fb2e0f8 100644 --- a/packages/solid-router/tests/useMatch.test.tsx +++ b/packages/solid-router/tests/useMatch.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -213,16 +213,14 @@ describe('useMatch', () => { }) } }) - try { - await fireEvent.click(screen.getByText('Other')) - await waitFor(() => expect(returnNavigation).toBeDefined()) - await returnNavigation + onTestFinished(unsubscribe) - expect(await screen.findByText('Item revision 2')).toBeInTheDocument() - expect(screen.queryByText('Other route')).not.toBeInTheDocument() - } finally { - unsubscribe() - } + await fireEvent.click(screen.getByText('Other')) + await waitFor(() => expect(returnNavigation).toBeDefined()) + await returnNavigation + + expect(await screen.findByText('Item revision 2')).toBeInTheDocument() + expect(screen.queryByText('Other route')).not.toBeInTheDocument() }) describe('when match is not found', () => { diff --git a/packages/solid-start-client/src/tests/hydrateStart.test.ts b/packages/solid-start-client/src/tests/hydrateStart.test.ts index 2d34400009..bbbdc56ce1 100644 --- a/packages/solid-start-client/src/tests/hydrateStart.test.ts +++ b/packages/solid-start-client/src/tests/hydrateStart.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { hydrateStart } from '../hydrateStart' const coreHydrateStart = vi.hoisted(() => vi.fn()) @@ -7,12 +7,14 @@ vi.mock('@tanstack/start-client-core/client', () => ({ hydrateStart: coreHydrateStart, })) -afterEach(() => { +const cleanup = () => { delete window.$_TSR coreHydrateStart.mockReset() -}) +} test('signals streaming cleanup after hydration succeeds', async () => { + onTestFinished(cleanup) + const router = {} coreHydrateStart.mockResolvedValue(router) const hydrated = vi.fn() @@ -23,6 +25,8 @@ test('signals streaming cleanup after hydration succeeds', async () => { }) test('signals streaming cleanup without hiding a hydration failure', async () => { + onTestFinished(cleanup) + const error = new Error('hydration failed') coreHydrateStart.mockRejectedValue(error) const hydrated = vi.fn() diff --git a/packages/start-client-core/tests/hydration-visible.test.ts b/packages/start-client-core/tests/hydration-visible.test.ts index c0fbac0dfd..1e75bf6ea5 100644 --- a/packages/start-client-core/tests/hydration-visible.test.ts +++ b/packages/start-client-core/tests/hydration-visible.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { visible } from '../src/hydration/visible' import type { HydrationPrefetchStrategy } from '../src/hydration/types' @@ -37,7 +37,6 @@ class IntersectionObserverMock implements IntersectionObserver { describe('visible hydration strategy', () => { let observers: Array - const cleanups: Array<() => void> = [] beforeEach(() => { observers = [] @@ -53,11 +52,9 @@ describe('visible hydration strategy', () => { } }, ) - }) - - afterEach(() => { - cleanups.splice(0).forEach((cleanup) => cleanup()) - vi.unstubAllGlobals() + onTestFinished(() => { + vi.unstubAllGlobals() + }) }) function observe( @@ -66,10 +63,19 @@ describe('visible hydration strategy', () => { callback: () => void, ) { const cleanup = strategy._s?.({ element, prefetch: callback }) - if (cleanup) { - cleanups.push(cleanup) + if (!cleanup) { + return cleanup + } + + let finished = false + const finish = () => { + if (!finished) { + finished = true + cleanup() + } } - return cleanup + onTestFinished(finish) + return finish } it('shares an observer and tracks multiple callbacks for one element', () => { diff --git a/packages/start-plugin-core/tests/rsbuild/post-build.test.ts b/packages/start-plugin-core/tests/rsbuild/post-build.test.ts index 26443c7e7c..e8b7054973 100644 --- a/packages/start-plugin-core/tests/rsbuild/post-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/post-build.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'pathe' @@ -12,6 +12,25 @@ vi.mock('@tanstack/start-server-core/constants', () => ({ describe('postBuildWithRsbuild', () => { it('imports server/index.js and accepts object fetch handlers', async () => { const serverOutputDirectory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-')) + onTestFinished(async () => { + await rm(serverOutputDirectory, { recursive: true, force: true }) + }) + + const originalPrerendering = process.env.TSS_PRERENDERING + const originalClientOutputDirectory = process.env.TSS_CLIENT_OUTPUT_DIR + onTestFinished(() => { + if (originalPrerendering === undefined) { + delete process.env.TSS_PRERENDERING + } else { + process.env.TSS_PRERENDERING = originalPrerendering + } + if (originalClientOutputDirectory === undefined) { + delete process.env.TSS_CLIENT_OUTPUT_DIR + } else { + process.env.TSS_CLIENT_OUTPUT_DIR = originalClientOutputDirectory + } + }) + const prerenderSpy = vi.fn(async ({ handler }: any) => { const response = await handler.request('/posts') expect(await response.text()).toBe('ok') @@ -39,22 +58,18 @@ describe('postBuildWithRsbuild', () => { const { postBuildWithRsbuild } = await import('../../src/rsbuild/post-build') - try { - await postBuildWithRsbuild({ - startConfig: { - prerender: { enabled: true, autoStaticPathsDiscovery: false }, - pages: [{ path: '/posts' }], - router: { basepath: '' }, - spa: { enabled: false, prerender: { outputPath: '/_shell' } }, - sitemap: { enabled: false }, - } as any, - clientOutputDirectory: '/client', - serverOutputDirectory, - }) - - expect(prerenderSpy).toHaveBeenCalledOnce() - } finally { - await rm(serverOutputDirectory, { recursive: true, force: true }) - } + await postBuildWithRsbuild({ + startConfig: { + prerender: { enabled: true, autoStaticPathsDiscovery: false }, + pages: [{ path: '/posts' }], + router: { basepath: '' }, + spa: { enabled: false, prerender: { outputPath: '/_shell' } }, + sitemap: { enabled: false }, + } as any, + clientOutputDirectory: '/client', + serverOutputDirectory, + }) + + expect(prerenderSpy).toHaveBeenCalledOnce() }) }) diff --git a/packages/start-plugin-core/tests/utils.test.ts b/packages/start-plugin-core/tests/utils.test.ts index 0b05da53a6..d65d3bb6df 100644 --- a/packages/start-plugin-core/tests/utils.test.ts +++ b/packages/start-plugin-core/tests/utils.test.ts @@ -1,8 +1,15 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') async function importUtilsWithPlatform(platform: NodeJS.Platform) { + onTestFinished(() => { + vi.resetModules() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + vi.resetModules() Object.defineProperty(process, 'platform', { ...originalPlatform, @@ -12,13 +19,6 @@ async function importUtilsWithPlatform(platform: NodeJS.Platform) { return await import('../src/utils') } -afterEach(() => { - vi.resetModules() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -}) - describe('normalizePath', () => { test('normalizes POSIX path segments on POSIX platforms', async () => { const { normalizePath } = await importUtilsWithPlatform('linux') diff --git a/packages/start-server-core/tests/early-hints.test.ts b/packages/start-server-core/tests/early-hints.test.ts index d56824a828..38e7b15df3 100644 --- a/packages/start-server-core/tests/early-hints.test.ts +++ b/packages/start-server-core/tests/early-hints.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { collectDynamicHintsFromMatches, collectStaticHintsFromManifest, @@ -15,10 +15,6 @@ import type { } from '@tanstack/router-core' describe('early hints', () => { - afterEach(() => { - vi.unstubAllEnvs() - }) - it('formats Link header values', () => { const hints = [ { @@ -406,6 +402,9 @@ describe('early hints', () => { it('does not create a collector in the dev server', () => { vi.stubEnv('TSS_DEV_SERVER', 'true') + onTestFinished(() => { + vi.unstubAllEnvs() + }) expect( createEarlyHintsCollector({ diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index 8d6724076b..76f38a0f7a 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -39,17 +39,14 @@ const createTestManifest = ( }, }) satisfies Manifest -const browserHistories: Array> = [] - const createTestBrowserHistory = () => { const history = createBrowserHistory() - browserHistories.push(history) + onTestFinished(() => history.destroy()) return history } afterEach(() => { cleanup() - browserHistories.splice(0).forEach((history) => history.destroy()) window.history.replaceState(null, 'root', '/') delete window.$_TSR }) @@ -314,77 +311,75 @@ describe('ssr HeadContent', () => { document.head.append(ssrStylesheet, ssrPreload) - try { - const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - - ), - }) + onTestFinished(() => { + ssrStylesheet.remove() + ssrPreload.remove() + }) - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - const { unmount } = render() + await router.load() - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/main.css"]', - ), - ).toHaveLength(1) - expect( - document.head.querySelectorAll( - 'link[rel="modulepreload"][href="/main.js"]', - ), - ).toHaveLength(1) - }) + const { unmount } = render() + await waitFor(() => { expect( - document.head.querySelector('link[rel="stylesheet"][href="/main.css"]'), - ).toBe(ssrStylesheet) + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(1) expect( - document.head.querySelector( + document.head.querySelectorAll( 'link[rel="modulepreload"][href="/main.js"]', ), - ).toBe(ssrPreload) + ).toHaveLength(1) + }) - unmount() + expect( + document.head.querySelector('link[rel="stylesheet"][href="/main.css"]'), + ).toBe(ssrStylesheet) + expect( + document.head.querySelector('link[rel="modulepreload"][href="/main.js"]'), + ).toBe(ssrPreload) - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/main.css"]', - ), - ).toHaveLength(1) - expect( - document.head.querySelectorAll( - 'link[rel="modulepreload"][href="/main.js"]', - ), - ).toHaveLength(0) - }) - } finally { - ssrStylesheet.remove() - ssrPreload.remove() - } + unmount() + + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/main.js"]', + ), + ).toHaveLength(0) + }) }) test('removes preserved SSR-rendered route preload links after navigation', async () => { @@ -400,90 +395,90 @@ describe('ssr HeadContent', () => { document.head.append(ssrStylesheet, ssrPreload) - try { - const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - - ), - }) + onTestFinished(() => { + document.head + .querySelectorAll( + 'link[rel="stylesheet"][href="/index.css"], link[rel="modulepreload"][href="/index.js"]', + ) + .forEach((element) => element.remove()) + }) - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () => Go to about page, - }) + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () =>
About
, - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - }) + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () =>
About
, + }) - router.ssr = { - manifest: { - routes: { - [indexRoute.id]: { - css: ['/index.css'], - preloads: ['/index.js'], - }, + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [indexRoute.id]: { + css: ['/index.css'], + preloads: ['/index.js'], }, }, - } + }, + } - await router.load() + await router.load() - render() + render() - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/index.css"]', - ), - ).toHaveLength(1) - expect( - document.head.querySelectorAll( - 'link[rel="modulepreload"][href="/index.js"]', - ), - ).toHaveLength(1) - }) + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/index.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/index.js"]', + ), + ).toHaveLength(1) + }) - await fireEvent.click( - screen.getByRole('link', { name: 'Go to about page' }), - ) + await fireEvent.click( + screen.getByRole('link', { name: 'Go to about page' }), + ) - await waitFor(() => { - expect(router.state.location.pathname).toBe('/about') - }) + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/index.css"]', - ), - ).toHaveLength(1) - expect( - document.head.querySelectorAll( - 'link[rel="modulepreload"][href="/index.js"]', - ), - ).toHaveLength(0) - }) - } finally { - document.head - .querySelectorAll( - 'link[rel="stylesheet"][href="/index.css"], link[rel="modulepreload"][href="/index.js"]', - ) - .forEach((element) => element.remove()) - } + await waitFor(() => { + expect( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/index.css"]', + ), + ).toHaveLength(1) + expect( + document.head.querySelectorAll( + 'link[rel="modulepreload"][href="/index.js"]', + ), + ).toHaveLength(0) + }) }) test('does not reuse one SSR-rendered head link for multiple managed tags', async () => { @@ -495,56 +490,56 @@ describe('ssr HeadContent', () => { document.head.append(ssrStylesheet) - try { - const rootRoute = createRootRoute({ - head: () => ({ - links: [{ rel: 'stylesheet', href: '/main.css' }], - }), - component: () => ( - <> - - - - - - ), - }) + onTestFinished(() => { + document.head + .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') + .forEach((element) => element.remove()) + }) - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const rootRoute = createRootRoute({ + head: () => ({ + links: [{ rel: 'stylesheet', href: '/main.css' }], + }), + component: () => ( + <> + + + + + + ), + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - render() + await router.load() - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/main.css"]', - ), - ).toHaveLength(2) - }) + render() + await waitFor(() => { expect( - document.head.querySelector('link[rel="stylesheet"][href="/main.css"]'), - ).toBe(ssrStylesheet) - } finally { - document.head - .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') - .forEach((element) => element.remove()) - } + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ).toHaveLength(2) + }) + + expect( + document.head.querySelector('link[rel="stylesheet"][href="/main.css"]'), + ).toBe(ssrStylesheet) }) test('does not preserve an SSR-rendered head link with stale attrs', async () => { @@ -557,57 +552,55 @@ describe('ssr HeadContent', () => { document.head.append(ssrStylesheet) - try { - const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - - ), - }) + onTestFinished(() => { + document.head + .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') + .forEach((element) => element.remove()) + }) - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - render() + await router.load() - await waitFor(() => { - expect( - document.head.querySelector( - 'link[rel="stylesheet"][href="/main.css"][crossorigin="use-credentials"]', - ), - ).toBeTruthy() - }) + render() + await waitFor(() => { expect( document.head.querySelector( - 'link[rel="stylesheet"][href="/main.css"][crossorigin="anonymous"]', + 'link[rel="stylesheet"][href="/main.css"][crossorigin="use-credentials"]', ), - ).toBe(ssrStylesheet) - } finally { - document.head - .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') - .forEach((element) => element.remove()) - } + ).toBeTruthy() + }) + + expect( + document.head.querySelector( + 'link[rel="stylesheet"][href="/main.css"][crossorigin="anonymous"]', + ), + ).toBe(ssrStylesheet) }) test('does not preserve an SSR-rendered head link with extra attrs', async () => { @@ -620,62 +613,62 @@ describe('ssr HeadContent', () => { document.head.append(ssrStylesheet) - try { - const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - - ), - }) + onTestFinished(() => { + document.head + .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') + .forEach((element) => element.remove()) + }) - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - render() + await router.load() - await waitFor(() => { - expect( - document.head.querySelectorAll( - 'link[rel="stylesheet"][href="/main.css"]', - ), - ).toHaveLength(2) - }) + render() - const links = Array.from( + await waitFor(() => { + expect( document.head.querySelectorAll( 'link[rel="stylesheet"][href="/main.css"]', ), - ) - expect(links).toContain(ssrStylesheet) - expect( - links.some( - (element) => - element !== ssrStylesheet && !element.hasAttribute('data-stale'), - ), - ).toBe(true) - } finally { - document.head - .querySelectorAll('link[rel="stylesheet"][href="/main.css"]') - .forEach((element) => element.remove()) - } + ).toHaveLength(2) + }) + + const links = Array.from( + document.head.querySelectorAll( + 'link[rel="stylesheet"][href="/main.css"]', + ), + ) + expect(links).toContain(ssrStylesheet) + expect( + links.some( + (element) => + element !== ssrStylesheet && !element.hasAttribute('data-stale'), + ), + ).toBe(true) }) test('renders runtime manifest inlineStyle', async () => { diff --git a/packages/vue-router/tests/errorComponent.test.tsx b/packages/vue-router/tests/errorComponent.test.tsx index 92fab168e6..eb2b4eea43 100644 --- a/packages/vue-router/tests/errorComponent.test.tsx +++ b/packages/vue-router/tests/errorComponent.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/vue' import { createControlledPromise } from '@tanstack/router-core' @@ -194,6 +194,10 @@ test('global catch boundary resets when a background child generation recovers', ).toBeInTheDocument() const invalidation = router.invalidate() + onTestFinished(async () => { + refresh.resolve(2) + await Promise.allSettled([invalidation]) + }) await vi.waitFor(() => expect(loaderCalls).toBe(2)) expect(screen.getByText('stale child render failed')).toBeInTheDocument() expect(screen.queryByText(/Recovered child revision/)).not.toBeInTheDocument() @@ -236,30 +240,27 @@ test('ancestor route errorComponent resets when a background child generation re vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) - let invalidation: Promise | undefined - try { - render() - expect( - await screen.findByText('Ancestor error: stale child render failed'), - ).toBeInTheDocument() + render() + expect( + await screen.findByText('Ancestor error: stale child render failed'), + ).toBeInTheDocument() - invalidation = router.invalidate({ - filter: (match) => match.routeId === childRoute.id, - }) - await vi.waitFor(() => expect(loaderCalls).toBe(2)) - expect( - screen.getByText('Ancestor error: stale child render failed'), - ).toBeInTheDocument() + const invalidation = router.invalidate({ + filter: (match) => match.routeId === childRoute.id, + }) + onTestFinished(async () => { refresh.resolve(2) - await invalidation + await Promise.allSettled([invalidation]) + }) - expect( - await screen.findByText('Recovered child revision 2'), - ).toBeInTheDocument() - } finally { - refresh.resolve(2) - if (invalidation) { - await Promise.allSettled([invalidation]) - } - } + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + expect( + screen.getByText('Ancestor error: stale child render failed'), + ).toBeInTheDocument() + refresh.resolve(2) + await invalidation + + expect( + await screen.findByText('Recovered child revision 2'), + ).toBeInTheDocument() }) diff --git a/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx index e8e0db4b67..0f757e7fb1 100644 --- a/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx +++ b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx @@ -1,6 +1,6 @@ import * as Vue from 'vue' import { renderToString } from 'vue/server-renderer' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { hydrate as hydrateRouter } from '@tanstack/router-core/ssr/client' import { Outlet, @@ -20,17 +20,6 @@ declare global { } } -const testCleanups: Array<() => void | Promise> = [] - -afterEach(async () => { - while (testCleanups.length) { - await testCleanups.pop()!() - } - vi.restoreAllMocks() - window.$_TSR = undefined - document.body.innerHTML = '' -}) - describe('hydrating a server-capped boundary lane', () => { test.each([ ['error', 'parent'], @@ -84,8 +73,11 @@ describe('hydrating a server-capped boundary lane', () => { }), }) serverRouter.isServer = true - testCleanups.push(() => serverRouter.serverSsr?.cleanup()) + onTestFinished(() => serverRouter.serverSsr?.cleanup()) window.$_TSR = await dehydrateToBootstrap(serverRouter) + onTestFinished(() => { + window.$_TSR = undefined + }) const serverMatches = serverRouter.stores.matches.get() expect(serverMatches).toHaveLength(3) @@ -125,13 +117,17 @@ describe('hydrating a server-capped boundary lane', () => { .spyOn(console, 'error') .mockImplementation(() => {}) const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => { + consoleError.mockRestore() + consoleWarn.mockRestore() + }) const clientApp = Vue.createSSRApp( Vue.defineComponent({ setup: () => () => , }), ) let clientAppMounted = false - testCleanups.push(() => { + onTestFinished(() => { if (clientAppMounted) { clientApp.unmount() } diff --git a/packages/vue-router/tests/issue-7986-retained-pending.test.tsx b/packages/vue-router/tests/issue-7986-retained-pending.test.tsx index 57e94e2ad0..708f06ab6a 100644 --- a/packages/vue-router/tests/issue-7986-retained-pending.test.tsx +++ b/packages/vue-router/tests/issue-7986-retained-pending.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen, waitFor } from '@testing-library/vue' -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { nextTick } from 'vue' import { @@ -13,43 +13,46 @@ import { } from '../src' import type { ControlledPromise } from '@tanstack/router-core' -const controlledPromises = new Set>() -const pendingOperations = new Set>() +function createTestHarness() { + const controlledPromises = new Set>() + const pendingOperations = new Set>() -afterEach(async () => { - try { - for (const promise of controlledPromises) { - if (promise.status === 'pending') { - promise.resolve() + onTestFinished(async () => { + try { + for (const promise of controlledPromises) { + if (promise.status === 'pending') { + promise.resolve() + } } + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(pendingOperations) + } finally { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() } - if (vi.isFakeTimers()) { - await vi.runAllTimersAsync() - } - await Promise.allSettled(pendingOperations) - } finally { - controlledPromises.clear() - pendingOperations.clear() - cleanup() - vi.useRealTimers() - vi.restoreAllMocks() + }) + + function controlled() { + const promise = createControlledPromise() + controlledPromises.add(promise) + return promise } -}) -const navigationDelay = 100 + function track(operation: Promise) { + pendingOperations.add(operation) + return operation + } -function controlled() { - const promise = createControlledPromise() - controlledPromises.add(promise) - return promise + return { controlled, track } } -function track(operation: Promise) { - pendingOperations.add(operation) - return operation -} +const navigationDelay = 100 function setup() { + const { controlled, track } = createTestHarness() const navigationBeforeLoadStarted = controlled() let beforeLoadCalls = 0 @@ -94,11 +97,11 @@ function setup() { defaultPendingMinMs: 1, }) - return { router, navigationBeforeLoadStarted } + return { router, navigationBeforeLoadStarted, track } } test('a search-only navigation retains successful UI while beforeLoad reruns', async () => { - const { router, navigationBeforeLoadStarted } = setup() + const { router, navigationBeforeLoadStarted, track } = setup() render() expect(await screen.findByTestId('content')).toHaveTextContent( 'project=p1 tab=default', @@ -139,7 +142,7 @@ test('a search-only navigation retains successful UI while beforeLoad reruns', a }) test('a path-param navigation retains successful UI while beforeLoad reruns', async () => { - const { router, navigationBeforeLoadStarted } = setup() + const { router, navigationBeforeLoadStarted, track } = setup() render() expect(await screen.findByTestId('content')).toHaveTextContent( 'project=p1 tab=default', @@ -179,6 +182,7 @@ test('a path-param navigation retains successful UI while beforeLoad reruns', as }) test('a blocking reload retains the exact successful match', async () => { + const { controlled, track } = createTestHarness() const reloadStarted = controlled() let loaderCalls = 0 @@ -252,6 +256,7 @@ test('a blocking reload retains the exact successful match', async () => { }) test('a cached success retries through pending UI when an error is mounted', async () => { + const { controlled, track } = createTestHarness() vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {}) const retryStarted = controlled() @@ -315,6 +320,7 @@ test('a cached success retries through pending UI when an error is mounted', asy }) test('a cache-only success retries through pending UI over mounted success', async () => { + const { controlled, track } = createTestHarness() const retryStarted = controlled() const retry = controlled() let loaderCalls = 0 @@ -371,6 +377,7 @@ test('a cache-only success retries through pending UI over mounted success', asy }) test('a success hidden below an error boundary retries through pending UI', async () => { + const { controlled, track } = createTestHarness() vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {}) const childReloadStarted = controlled() @@ -448,6 +455,7 @@ test('a success hidden below an error boundary retries through pending UI', asyn }) test('a global not-found destination does not retain the mounted root success', async () => { + const { controlled, track } = createTestHarness() const missingStarted = controlled() const missingLoader = controlled() let loaderCalls = 0 @@ -499,6 +507,7 @@ test('a global not-found destination does not retain the mounted root success', }) test('lazy fuzzy-boundary relocation retains the mounted parent', async () => { + const { controlled, track } = createTestHarness() const lazyStarted = controlled() const lazyRoute = controlled() const parentReloadStarted = controlled() @@ -586,6 +595,7 @@ test('lazy fuzzy-boundary relocation retains the mounted parent', async () => { }) test('a superseding navigation replaces an unrelated pending presentation', async () => { + const { controlled, track } = createTestHarness() const otherStarted = controlled() const otherLoader = controlled() const pageReloadStarted = controlled() diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 8d9e4a67ac..14bd2b3cda 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -1,5 +1,14 @@ import * as Vue from 'vue' -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + test, + vi, +} from 'vitest' import { cleanup, fireEvent, @@ -2465,6 +2474,7 @@ describe('Link', () => { const homeLink = await screen.findByTestId('home-link') const consoleWarnSpy = vi.spyOn(console, 'warn') + onTestFinished(() => consoleWarnSpy.mockRestore()) fireEvent.click(homeLink) @@ -2474,8 +2484,6 @@ describe('Link', () => { expect(homeHeading).toBeInTheDocument() expect(consoleWarnSpy).not.toHaveBeenCalled() - - consoleWarnSpy.mockRestore() }) test('when navigating from /posts to ../posts/$postId', async () => { @@ -3027,6 +3035,7 @@ describe('Link', () => { expect(post1Heading).toBeInTheDocument() const consoleWarnSpy = vi.spyOn(console, 'warn') + onTestFinished(() => consoleWarnSpy.mockRestore()) const usersLink = await screen.findByTestId('users-link') fireEvent.click(usersLink) @@ -3044,8 +3053,6 @@ describe('Link', () => { expect(user1Heading).toBeInTheDocument() expect(consoleWarnSpy).not.toHaveBeenCalled() - - consoleWarnSpy.mockRestore() }) test('when navigating from /posts/$postId to ./info and the current route is /posts/$postId/details', async () => { @@ -5711,6 +5718,9 @@ describe('createLink', () => { const originalOpen = window.open const openMock = vi.fn() window.open = openMock + onTestFinished(() => { + window.open = originalOpen + }) render() @@ -5728,8 +5738,6 @@ describe('createLink', () => { }) await expect(screen.findByTestId('posts-heading')).rejects.toThrow() - - window.open = originalOpen }) it('should allow override of target prop even when custom component sets it', async () => { diff --git a/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx index 8cab6d41f0..644d025aaa 100644 --- a/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx +++ b/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen } from '@testing-library/vue' -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { RouterProvider, @@ -8,20 +8,24 @@ import { createRouter, } from '../src' -const testCleanups: Array<() => void | Promise> = [] - -afterEach(async () => { - vi.useRealTimers() - while (testCleanups.length) { - await testCleanups.pop()!() - } - cleanup() -}) - test('a continuously visible fallback keeps its deadline across replacement loads', async () => { const firstReload = createControlledPromise() const secondReload = createControlledPromise() const reloads = [firstReload, secondReload] + const invalidations: Array> = [] + onTestFinished(async () => { + try { + firstReload.resolve() + secondReload.resolve() + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(invalidations) + } finally { + cleanup() + vi.useRealTimers() + } + }) let loaderCall = 0 const rootRoute = createRootRoute({ @@ -45,12 +49,7 @@ test('a continuously visible fallback keeps its deadline across replacement load vi.useFakeTimers() const firstInvalidation = router.invalidate({ forcePending: true }) - const invalidations = [firstInvalidation] - testCleanups.push(async () => { - firstReload.resolve() - secondReload.resolve() - await Promise.allSettled(invalidations) - }) + invalidations.push(firstInvalidation) await vi.advanceTimersByTimeAsync(0) expect(screen.getByTestId('pending')).toBeInTheDocument() diff --git a/packages/vue-router/tests/renderRouterToStream.test.tsx b/packages/vue-router/tests/renderRouterToStream.test.tsx index 60bfa9a8af..bca3b1bbf5 100644 --- a/packages/vue-router/tests/renderRouterToStream.test.tsx +++ b/packages/vue-router/tests/renderRouterToStream.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { attachRouterServerSsrUtils, normalizeSsrResponse, @@ -95,26 +95,26 @@ describe('renderRouterToStream - sync setup failures', () => { const router = await buildRouter() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/'), - router, - responseHeaders: new Headers(), - App: { template: '
' } as any, - }), - ) - - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - - expect(terminated).toBe(true) - } finally { + onTestFinished(() => { errorSpy.mockRestore() router.serverSsr?.cleanup() - } + }) + + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + App: { template: '
' } as any, + }), + ) + + const terminated = await Promise.race([ + drainBody(response), + new Promise((resolve) => setTimeout(() => resolve(false), 2000)), + ]) + + expect(terminated).toBe(true) }) test('request abort drops later Vue writes and terminates the response', async () => { @@ -132,35 +132,35 @@ describe('renderRouterToStream - sync setup failures', () => { const router = await buildRouter() const abortController = new AbortController() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const response = unwrapResponse( - await renderRouterToStream({ - request: new Request('http://localhost/', { - signal: abortController.signal, - }), - router, - responseHeaders: new Headers(), - App: { template: '
' } as any, + onTestFinished(() => { + errorSpy.mockRestore() + router.serverSsr?.cleanup() + }) + + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/', { + signal: abortController.signal, }), - ) + router, + responseHeaders: new Headers(), + App: { template: '
' } as any, + }), + ) - expect(vueWriter).toBeDefined() - abortController.abort(new Error('client-gone')) + expect(vueWriter).toBeDefined() + abortController.abort(new Error('client-gone')) - await expect( - vueWriter!.write(new TextEncoder().encode('
')), - ).resolves.toBeUndefined() - expect(response.body).not.toBeNull() + await expect( + vueWriter!.write(new TextEncoder().encode('
')), + ).resolves.toBeUndefined() + expect(response.body).not.toBeNull() - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) + const terminated = await Promise.race([ + drainBody(response), + new Promise((resolve) => setTimeout(() => resolve(false), 2000)), + ]) - expect(terminated).toBe(true) - } finally { - errorSpy.mockRestore() - router.serverSsr?.cleanup() - } + expect(terminated).toBe(true) }) }) diff --git a/packages/vue-router/tests/router.test.tsx b/packages/vue-router/tests/router.test.tsx index 543a32548e..2b41e077c3 100644 --- a/packages/vue-router/tests/router.test.tsx +++ b/packages/vue-router/tests/router.test.tsx @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + vi, +} from 'vitest' import { cleanup, fireEvent, @@ -817,11 +825,11 @@ describe('router emits events during rendering', () => { }) const unsub = router.subscribe('onResolved', mockFn1) + onTestFinished(unsub) await router.load() render() await waitFor(() => expect(mockFn1).toBeCalled()) - unsub() }) it('after a navigation, should have emitted the "onResolved" event twice', async () => { @@ -830,13 +838,13 @@ describe('router emits events during rendering', () => { }) const unsub = router.subscribe('onResolved', mockFn1) + onTestFinished(unsub) await router.load() render() await sleep(0) await router.navigate({ to: '/$', params: { _splat: 'tanner' } }) await waitFor(() => expect(mockFn1).toBeCalledTimes(2)) - unsub() }) it('should emit the "onRendered" event when a route renders, after navigation, and after param/search updates', async () => { @@ -847,6 +855,7 @@ describe('router emits events during rendering', () => { const mockOnRendered = vi.fn() const unsub = router.subscribe('onRendered', mockOnRendered) + onTestFinished(unsub) await router.load() await waitFor(() => expect(mockOnRendered).toBeCalledTimes(0)) @@ -879,8 +888,6 @@ describe('router emits events during rendering', () => { expect(mockOnRendered.mock.calls[3]?.[0]?.toLocation.search.root).toBe( 'search-change', ) - - unsub() }) it('during initial load, should emit the "onBeforeRouteMount" and "onResolved" events in the correct order', async () => { @@ -897,6 +904,10 @@ describe('router emits events during rendering', () => { mockOnBeforeRouteMount, ) const unsubResolved = router.subscribe('onResolved', mockOnResolved) + onTestFinished(() => { + unsubBeforeRouteMount() + unsubResolved() + }) await router.load() render() @@ -917,9 +928,6 @@ describe('router emits events during rendering', () => { } else { throw new Error('onBeforeRouteMount should be emitted before onResolved.') } - - unsubBeforeRouteMount() - unsubResolved() }) }) @@ -1542,6 +1550,7 @@ describe('history: History gives correct notifcations and state', () => { const unsub = router.history.subscribe(({ action }) => { results.push(action) }) + onTestFinished(unsub) const postsButton = await screen.findByRole('button', { name: 'Posts' }) @@ -1562,8 +1571,6 @@ describe('history: History gives correct notifcations and state', () => { expect(window.location.pathname).toBe('/') expect(results).toEqual([{ type: 'PUSH' }, { type: 'BACK' }]) - - unsub() }) it('should work more complex scenario', async () => { @@ -1580,6 +1587,7 @@ describe('history: History gives correct notifcations and state', () => { const unsub = router.history.subscribe(({ action }) => { results.push(action) }) + onTestFinished(unsub) const replaceButton = await screen.findByRole('button', { name: 'Replace' }) @@ -1634,8 +1642,6 @@ describe('history: History gives correct notifcations and state', () => { { type: 'BACK' }, { type: 'GO', index: 1 }, ]) - - unsub() }) }) diff --git a/packages/vue-router/tests/transitioner-idle-after-render.test.tsx b/packages/vue-router/tests/transitioner-idle-after-render.test.tsx index 4cfd74f86f..24f23d3994 100644 --- a/packages/vue-router/tests/transitioner-idle-after-render.test.tsx +++ b/packages/vue-router/tests/transitioner-idle-after-render.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen } from '@testing-library/vue' -import { afterEach, expect, test } from 'vitest' +import { afterEach, expect, onTestFinished, test } from 'vitest' import { Outlet, RouterProvider, @@ -37,10 +37,10 @@ test('onResolved fires only after Vue commits the destination DOM', async () => const unsubscribe = router.subscribe('onResolved', () => { destinationWasRenderedWhenResolved.push(screen.queryByText('Next') !== null) }) + onTestFinished(unsubscribe) await router.navigate({ to: '/next' }) expect(await screen.findByText('Next')).toBeInTheDocument() expect(destinationWasRenderedWhenResolved).toEqual([true]) - unsubscribe() }) diff --git a/packages/vue-router/tests/transitioner-remount-rendered.test.tsx b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx index e39aaeee06..dd4fbf5d3e 100644 --- a/packages/vue-router/tests/transitioner-remount-rendered.test.tsx +++ b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx @@ -1,5 +1,5 @@ import { cleanup, render, screen, waitFor } from '@testing-library/vue' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { Outlet, @@ -47,18 +47,18 @@ test('onRendered runs after the destination DOM has committed', async () => { const unsubscribe = router.subscribe('onRendered', () => { destinationWasRendered.push(screen.queryByText('Next') !== null) }) + onTestFinished(unsubscribe) await router.navigate({ to: '/next' }) await waitFor(() => expect(destinationWasRendered).toHaveLength(1)) expect(destinationWasRendered).toEqual([true]) - - unsubscribe() }) test('onRendered fires for a same-href navigation with a new history key', async () => { const { router } = setup() const onRendered = vi.fn() const unsubscribe = router.subscribe('onRendered', onRendered) + onTestFinished(unsubscribe) render() expect(await screen.findByText('Index')).toBeTruthy() await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) @@ -76,6 +76,4 @@ test('onRendered fires for a same-href navigation with a new history key', async expect(event.fromLocation?.href).toBe('/') expect(event.toLocation.href).toBe('/') expect(event.hrefChanged).toBe(false) - - unsubscribe() }) diff --git a/packages/vue-router/tests/transitioner-remount.test.tsx b/packages/vue-router/tests/transitioner-remount.test.tsx index 836f4b7c89..374fef3c8a 100644 --- a/packages/vue-router/tests/transitioner-remount.test.tsx +++ b/packages/vue-router/tests/transitioner-remount.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/vue' import { createMemoryHistory } from '@tanstack/history' import { @@ -40,6 +40,7 @@ describe('Transitioner remount', () => { it('does not load after the provider unmounts', async () => { const { history, router } = setup() const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) const first = render() expect(await screen.findByText('Index')).toBeTruthy() @@ -57,8 +58,6 @@ describe('Transitioner remount', () => { expect(router.history.location.pathname).toBe('/next') // ...but the router never processed it, so its committed state stayed put. expect(router.state.location.pathname).toBe('/') - - loadSpy.mockRestore() }) // Remounting the same router instance must re-establish the subscription so @@ -68,6 +67,7 @@ describe('Transitioner remount', () => { // Spy before the first mount so the subscription captures the spy by // reference - both the first and second mounts subscribe with it. const loadSpy = vi.spyOn(router, 'load') + onTestFinished(() => loadSpy.mockRestore()) const first = render() expect(await screen.findByText('Index')).toBeTruthy() @@ -86,7 +86,5 @@ describe('Transitioner remount', () => { expect(await screen.findByText('Next')).toBeTruthy() expect(router.state.location.pathname).toBe('/next') await waitFor(() => expect(loadSpy).toHaveBeenCalledTimes(1)) - - loadSpy.mockRestore() }) }) diff --git a/packages/vue-router/tests/useMatch.test.tsx b/packages/vue-router/tests/useMatch.test.tsx index 7a73cdbea7..c18f1ce368 100644 --- a/packages/vue-router/tests/useMatch.test.tsx +++ b/packages/vue-router/tests/useMatch.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -223,16 +223,14 @@ describe('useMatch', () => { }) } }) - try { - await fireEvent.click(screen.getByText('Other')) - await waitFor(() => expect(returnNavigation).toBeDefined()) - await returnNavigation + onTestFinished(unsubscribe) - expect(await screen.findByText('Item revision 2')).toBeInTheDocument() - expect(screen.queryByText('Other route')).not.toBeInTheDocument() - } finally { - unsubscribe() - } + await fireEvent.click(screen.getByText('Other')) + await waitFor(() => expect(returnNavigation).toBeDefined()) + await returnNavigation + + expect(await screen.findByText('Item revision 2')).toBeInTheDocument() + expect(screen.queryByText('Other route')).not.toBeInTheDocument() }) test('an outgoing component never observes its own match disappear', async () => { diff --git a/packages/vue-start-client/src/hydrateStart.test.ts b/packages/vue-start-client/src/hydrateStart.test.ts index c338122c6c..74163214b6 100644 --- a/packages/vue-start-client/src/hydrateStart.test.ts +++ b/packages/vue-start-client/src/hydrateStart.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { expect, onTestFinished, test, vi } from 'vitest' import { hydrateStart } from './hydrateStart' const coreHydrateStart = vi.hoisted(() => vi.fn()) @@ -7,12 +7,14 @@ vi.mock('@tanstack/start-client-core/client', () => ({ hydrateStart: coreHydrateStart, })) -afterEach(() => { +const cleanup = () => { delete window.$_TSR coreHydrateStart.mockReset() -}) +} test('signals streaming cleanup without hiding a hydration failure', async () => { + onTestFinished(cleanup) + const error = new Error('hydration failed') coreHydrateStart.mockRejectedValue(error) const hydrated = vi.fn()