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('')
+ 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('
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()