1import {
2 createMemoryHistory,
3 createRootRoute,
4 createRouter,
5 RouterProvider,
6 type AnyRouter,
7} from "@tanstack/react-router";
8import { act as reactAct, createContext, use, type ReactNode } from "react";
9import { createRoot } from "react-dom/client";
10import { vi } from "vite-plus/test";
11import { page, utils, type Locator, type LocatorSelectors } from "vite-plus/test/browser";
12
13declare global {
14 var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
15}
16
17// React warns about every update outside act() while this flag is on, and a real
18// browser drives updates from userEvent, timers and network that act() cannot wrap.
19// So it is on only while act() runs, and nested calls restore the outer value.
20export async function act<T>(callback: () => T | Promise<T>): Promise<T> {
21 const previous = globalThis.IS_REACT_ACT_ENVIRONMENT;
22 globalThis.IS_REACT_ACT_ENVIRONMENT = true;
23 try {
24 return await reactAct(async () => await callback());
25 } finally {
26 globalThis.IS_REACT_ACT_ENVIRONMENT = previous;
27 }
28}
29
30export interface RenderOptions {
31 /**
32 * Element to render into, for tests that size or style it before the first render.
33 * The screen owns it from then on and removes it on dispose.
34 */
35 readonly container?: HTMLElement;
36 /** Initial location of the test router, or `false` to render without one. */
37 readonly router?: string | false;
38}
39
40/** Queries are scoped to the container; use `page` for portals such as menus and dialogs. */
41export interface Screen extends LocatorSelectors, AsyncDisposable {
42 readonly container: HTMLElement;
43 readonly locator: Locator;
44 readonly router: AnyRouter | undefined;
45 readonly rerender: (ui: ReactNode) => Promise<void>;
46 /** Unmounts early, to test cleanup effects. Dispose still removes the container. */
47 readonly unmount: () => Promise<void>;
48}
49
50const RoutedUi = createContext<ReactNode>(null);
51
52function RoutedUiOutlet() {
53 return use(RoutedUi);
54}
55
56// Links and navigation hooks need a router even when the test is not about routing.
57function createTestRouter(path: string): AnyRouter {
58 return createRouter({
59 routeTree: createRootRoute({ component: RoutedUiOutlet }),
60 history: createMemoryHistory({ initialEntries: [path] }),
61 // The router's error UI would otherwise turn a crash into a passing negative assertion.
62 disableGlobalCatchBoundary: true,
63 });
64}
65
66/**
67 * Renders `ui` for one test. Acquire it with `await using` so the tree unmounts and the
68 * container and router go away when the test ends, pass or fail.
69 */
70export async function render(
71 ui: ReactNode,
72 { container = document.createElement("div"), router: path = "/" }: RenderOptions = {},
73): Promise<Screen> {
74 if (!container.isConnected) document.body.append(container);
75 const root = createRoot(container);
76 const router = path === false ? undefined : createTestRouter(path);
77 // The router element is created once so a rerender updates the tree instead of remounting it.
78 const routerElement = router && <RouterProvider router={router} />;
79 const wrap = (node: ReactNode) =>
80 routerElement ? <RoutedUi value={node}>{routerElement}</RoutedUi> : node;
81
82 let mounted = true;
83 const unmount = async () => {
84 if (!mounted) return;
85 mounted = false;
86 await act(() => root.unmount());
87 };
88 const screen: Screen = {
89 ...utils.getElementLocatorSelectors(container),
90 container,
91 locator: page.elementLocator(container),
92 router,
93 rerender: (next) => act(() => root.render(wrap(next))),
94 unmount,
95 async [Symbol.asyncDispose]() {
96 try {
97 await unmount();
98 } finally {
99 container.remove();
100 }
101 },
102 };
103
104 try {
105 await screen.rerender(ui);
106 } catch (error) {
107 await screen[Symbol.asyncDispose]();
108 throw error;
109 }
110 return screen;
111}
112
113/** Fake timers for one test: `using _timers = fakeTimers()`. */
114export function fakeTimers(config?: Parameters<typeof vi.useFakeTimers>[0]): Disposable {
115 vi.useFakeTimers(config);
116 return { [Symbol.dispose]: () => void vi.useRealTimers() };
117}
118
119/** Replaces one global for one test and restores exactly that global on dispose. */
120export function stubGlobal(name: string, value: unknown): Disposable {
121 const original = Object.getOwnPropertyDescriptor(globalThis, name);
122 Object.defineProperty(globalThis, name, {
123 value,
124 configurable: true,
125 enumerable: true,
126 writable: true,
127 });
128 return {
129 [Symbol.dispose]() {
130 if (original) Object.defineProperty(globalThis, name, original);
131 else Reflect.deleteProperty(globalThis, name);
132 },
133 };
134}