chore(site): update and refactor all custom hook tests that rely on React Router (#12219)

* chore: rename useTab to useSearchParamsKey and add test

* chore: mark old renderHookWithAuth as deprecated (temp)

* fix: update imports for useResourcesNav

* refactor: change API for useSearchParamsKey

* chore: let user pass in their own URLSearchParams value

* refactor: clean up comments for clarity

* fix: update import

* wip: commit progress on useWorkspaceDuplication revamp

* chore: migrate duplication test to new helper

* refactor: update code for clarity

* refactor: reorder test cases for clarity

* refactor: split off hook helper into separate file

* refactor: remove reliance on internal React Router state property

* refactor: move variables around for more clarity

* refactor: more updates for clarity

* refactor: reorganize test cases for clarity

* refactor: clean up test cases for useWorkspaceDupe

* refactor: clean up test cases for useWorkspaceDupe
This commit is contained in:
Michael Smith
2024-03-08 18:31:01 -05:00
committed by GitHub
parent cf4f56dc2f
commit 4d42c07c72
11 changed files with 437 additions and 157 deletions
-1
View File
@@ -2,4 +2,3 @@ export * from "./useClickable";
export * from "./useClickableTableRow";
export * from "./useClipboard";
export * from "./usePagination";
export * from "./useTab";
+8 -4
View File
@@ -1,5 +1,5 @@
import { waitFor } from "@testing-library/react";
import { renderHookWithAuth } from "testHelpers/renderHelpers";
import { renderHookWithAuth } from "testHelpers/hooks";
import {
type PaginatedData,
type UsePaginatedQueryOptions,
@@ -23,9 +23,13 @@ function render<
route?: `/?page=${string}`,
) {
return renderHookWithAuth(({ options }) => usePaginatedQuery(options), {
route,
path: "/",
initialProps: { options },
routingOptions: {
route,
path: "/",
},
renderOptions: {
initialProps: { options },
},
});
}
+134
View File
@@ -0,0 +1,134 @@
import { act, waitFor } from "@testing-library/react";
import { renderHookWithAuth } from "testHelpers/hooks";
import { useSearchParamsKey } from "./useSearchParamsKey";
/**
* Tried to extract the setup logic into one place, but it got surprisingly
* messy. Went with straightforward approach of calling things individually
*
* @todo See if there's a way to test the interaction with the history object
* (particularly, for replace behavior). It's traditionally very locked off, and
* React Router gives you no way of interacting with it directly.
*/
describe(useSearchParamsKey.name, () => {
describe("Render behavior", () => {
it("Returns empty string if hook key does not exist in URL, and there is no default value", async () => {
const { result } = await renderHookWithAuth(
() => useSearchParamsKey({ key: "blah" }),
{ routingOptions: { route: `/` } },
);
expect(result.current.value).toEqual("");
});
it("Returns out 'defaultValue' property if defined while hook key does not exist in URL", async () => {
const defaultValue = "dogs";
const { result } = await renderHookWithAuth(
() => useSearchParamsKey({ key: "blah", defaultValue }),
{ routingOptions: { route: `/` } },
);
expect(result.current.value).toEqual(defaultValue);
});
it("Returns out URL value if key exists in URL (always ignoring default value)", async () => {
const key = "blah";
const value = "cats";
const { result } = await renderHookWithAuth(
() => useSearchParamsKey({ key, defaultValue: "I don't matter" }),
{ routingOptions: { route: `/?${key}=${value}` } },
);
expect(result.current.value).toEqual(value);
});
it("Does not have methods change previous values if 'key' argument changes during re-renders", async () => {
const readonlyKey = "readonlyKey";
const mutableKey = "mutableKey";
const initialReadonlyValue = "readonly";
const initialMutableValue = "mutable";
const { result, rerender, getLocationSnapshot } =
await renderHookWithAuth(({ key }) => useSearchParamsKey({ key }), {
routingOptions: {
route: `/?${readonlyKey}=${initialReadonlyValue}&${mutableKey}=${initialMutableValue}`,
},
renderOptions: { initialProps: { key: readonlyKey } },
});
const swapValue = "dogs";
await rerender({ key: mutableKey });
act(() => result.current.setValue(swapValue));
await waitFor(() => expect(result.current.value).toEqual(swapValue));
const snapshot1 = getLocationSnapshot();
expect(snapshot1.search.get(readonlyKey)).toEqual(initialReadonlyValue);
expect(snapshot1.search.get(mutableKey)).toEqual(swapValue);
act(() => result.current.deleteValue());
await waitFor(() => expect(result.current.value).toEqual(""));
const snapshot2 = getLocationSnapshot();
expect(snapshot2.search.get(readonlyKey)).toEqual(initialReadonlyValue);
expect(snapshot2.search.get(mutableKey)).toEqual(null);
});
});
describe("setValue method", () => {
it("Updates state and URL when called with a new value", async () => {
const key = "blah";
const initialValue = "cats";
const { result, getLocationSnapshot } = await renderHookWithAuth(
() => useSearchParamsKey({ key }),
{ routingOptions: { route: `/?${key}=${initialValue}` } },
);
const newValue = "dogs";
act(() => result.current.setValue(newValue));
await waitFor(() => expect(result.current.value).toEqual(newValue));
const { search } = getLocationSnapshot();
expect(search.get(key)).toEqual(newValue);
});
});
describe("deleteValue method", () => {
it("Clears value for the given key from the state and URL when called", async () => {
const key = "blah";
const initialValue = "cats";
const { result, getLocationSnapshot } = await renderHookWithAuth(
() => useSearchParamsKey({ key }),
{ routingOptions: { route: `/?${key}=${initialValue}` } },
);
act(() => result.current.deleteValue());
await waitFor(() => expect(result.current.value).toEqual(""));
const { search } = getLocationSnapshot();
expect(search.get(key)).toEqual(null);
});
});
describe("Override behavior", () => {
it("Will dispatch state changes through custom URLSearchParams value if provided", async () => {
const key = "love";
const initialValue = "dogs";
const customParams = new URLSearchParams({ [key]: initialValue });
const { result } = await renderHookWithAuth(
({ key }) => useSearchParamsKey({ key, searchParams: customParams }),
{
routingOptions: { route: `/?=${key}=${initialValue}` },
renderOptions: { initialProps: { key } },
},
);
const newValue = "all animals";
act(() => result.current.setValue(newValue));
await waitFor(() => expect(customParams.get(key)).toEqual(newValue));
});
});
});
+41
View File
@@ -0,0 +1,41 @@
import { useSearchParams } from "react-router-dom";
export type UseSearchParamsKeyConfig = Readonly<{
key: string;
searchParams?: URLSearchParams;
defaultValue?: string;
replace?: boolean;
}>;
export type UseSearchParamKeyResult = Readonly<{
value: string;
setValue: (newValue: string) => void;
deleteValue: () => void;
}>;
export const useSearchParamsKey = (
config: UseSearchParamsKeyConfig,
): UseSearchParamKeyResult => {
// Cannot use function update form for setSearchParams, because by default, it
// will always be linked to innerSearchParams, ignoring the config's params
const [innerSearchParams, setSearchParams] = useSearchParams();
const {
key,
searchParams = innerSearchParams,
defaultValue = "",
replace = true,
} = config;
return {
value: searchParams.get(key) ?? defaultValue,
setValue: (newValue) => {
searchParams.set(key, newValue);
setSearchParams(searchParams, { replace });
},
deleteValue: () => {
searchParams.delete(key);
setSearchParams(searchParams, { replace });
},
};
};
-19
View File
@@ -1,19 +0,0 @@
import { useSearchParams } from "react-router-dom";
export interface UseTabResult {
value: string;
set: (value: string) => void;
}
export const useTab = (tabKey: string, defaultValue: string): UseTabResult => {
const [searchParams, setSearchParams] = useSearchParams();
const value = searchParams.get(tabKey) ?? defaultValue;
return {
value,
set: (value: string) => {
searchParams.set(tabKey, value);
setSearchParams(searchParams, { replace: true });
},
};
};
@@ -1,40 +1,45 @@
import { waitFor } from "@testing-library/react";
import { act, waitFor } from "@testing-library/react";
import type { Workspace } from "api/typesGenerated";
import * as M from "testHelpers/entities";
import { renderHookWithAuth } from "testHelpers/renderHelpers";
import { MockWorkspace } from "testHelpers/entities";
import {
type GetLocationSnapshot,
renderHookWithAuth,
} from "testHelpers/hooks";
import * as M from "../../testHelpers/entities";
import CreateWorkspacePage from "./CreateWorkspacePage";
import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
function render(workspace?: Workspace) {
return renderHookWithAuth(
({ workspace }) => {
return useWorkspaceDuplication(workspace);
},
({ workspace }) => useWorkspaceDuplication(workspace),
{
initialProps: { workspace },
extraRoutes: [
{
path: "/templates/:template/workspace",
element: <CreateWorkspacePage />,
},
],
renderOptions: { initialProps: { workspace } },
routingOptions: {
extraRoutes: [
{
path: "/templates/:template/workspace",
element: <CreateWorkspacePage />,
},
],
},
},
);
}
type RenderResult = Awaited<ReturnType<typeof render>>;
type RenderHookResult = RenderResult["result"];
async function performNavigation(
result: RenderResult["result"],
router: RenderResult["router"],
result: RenderHookResult,
getLocationSnapshot: GetLocationSnapshot,
) {
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
result.current.duplicateWorkspace();
act(() => result.current.duplicateWorkspace());
const templateName = MockWorkspace.template_name;
return waitFor(() => {
expect(router.state.location.pathname).toEqual(
`/templates/${M.MockWorkspace.template_name}/workspace`,
);
const { pathname } = getLocationSnapshot();
expect(pathname).toEqual(`/templates/${templateName}/workspace`);
});
}
@@ -44,28 +49,23 @@ describe(`${useWorkspaceDuplication.name}`, () => {
expect(result.current.isDuplicationReady).toBe(false);
for (let i = 0; i < 10; i++) {
rerender({ workspace: undefined });
await rerender({ workspace: undefined });
expect(result.current.isDuplicationReady).toBe(false);
}
});
it("Will become ready when workspace is provided and build params are successfully fetched", async () => {
const { result } = await render(M.MockWorkspace);
const { result } = await render(MockWorkspace);
expect(result.current.isDuplicationReady).toBe(false);
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
});
it("Is able to navigate the user to the workspace creation page", async () => {
const { result, router } = await render(M.MockWorkspace);
await performNavigation(result, router);
const { result, getLocationSnapshot } = await render(MockWorkspace);
await performNavigation(result, getLocationSnapshot);
});
test("Navigating populates the URL search params with the workspace's build params", async () => {
const { result, router } = await render(M.MockWorkspace);
await performNavigation(result, router);
const parsedParams = new URLSearchParams(router.state.location.search);
const mockBuildParams = [
M.MockWorkspaceBuildParameter1,
M.MockWorkspaceBuildParameter2,
@@ -74,25 +74,29 @@ describe(`${useWorkspaceDuplication.name}`, () => {
M.MockWorkspaceBuildParameter5,
];
const { result, getLocationSnapshot } = await render(MockWorkspace);
await performNavigation(result, getLocationSnapshot);
const { search } = getLocationSnapshot();
for (const { name, value } of mockBuildParams) {
const key = `param.${name}`;
expect(parsedParams.get(key)).toEqual(value);
expect(search.get(key)).toEqual(value);
}
});
test("Navigating appends other necessary metadata to the search params", async () => {
const { result, router } = await render(M.MockWorkspace);
await performNavigation(result, router);
const parsedParams = new URLSearchParams(router.state.location.search);
const extraMetadataEntries = [
const extraMetadataEntries: readonly [string, string][] = [
["mode", "duplicate"],
["name", `${M.MockWorkspace.name}-copy`],
["version", M.MockWorkspace.template_active_version_id],
] as const;
["name", `${MockWorkspace.name}-copy`],
["version", MockWorkspace.template_active_version_id],
];
const { result, getLocationSnapshot } = await render(MockWorkspace);
await performNavigation(result, getLocationSnapshot);
const { search } = getLocationSnapshot();
for (const [key, value] of extraMetadataEntries) {
expect(parsedParams.get(key)).toBe(value);
expect(search.get(key)).toBe(value);
}
});
});
@@ -17,7 +17,7 @@ import {
import { Stack } from "components/Stack/Stack";
import { Stats, StatsItem } from "components/Stats/Stats";
import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { useTab } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { DashboardFullPage } from "modules/dashboard/DashboardLayout";
import { AgentLogs, useAgentLogs } from "modules/resources/AgentLogs";
import {
@@ -51,14 +51,17 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
activeBuildNumber,
}) => {
const theme = useTheme();
const tab = useTab(LOGS_TAB_KEY, "build");
const tabState = useSearchParamsKey({
key: LOGS_TAB_KEY,
defaultValue: "build",
});
if (!build) {
return <Loader />;
}
const agents = build.resources.flatMap((r) => r.agents ?? []);
const selectedAgent = agents.find((a) => a.id === tab.value);
const selectedAgent = agents.find((a) => a.id === tabState.value);
return (
<DashboardFullPage>
@@ -141,7 +144,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
</Sidebar>
<div css={{ height: "100%", overflowY: "auto", width: "100%" }}>
<Tabs active={tab.value}>
<Tabs active={tabState.value}>
<TabsList>
<TabLink to={`?${LOGS_TAB_KEY}=build`} value="build">
Build
@@ -187,7 +190,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
</Alert>
)}
{tab.value === "build" ? (
{tabState.value === "build" ? (
<BuildLogsContent logs={logs} />
) : (
<AgentLogsContent agent={selectedAgent!} />
+5 -6
View File
@@ -8,7 +8,7 @@ import { useNavigate } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
import { useTab } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { AgentRow } from "modules/resources/AgentRow";
import { HistorySidebar } from "./HistorySidebar";
import type { WorkspacePermissions } from "./permissions";
@@ -88,13 +88,12 @@ export const Workspace: FC<WorkspaceProps> = ({
const transitionStats =
template !== undefined ? ActiveTransition(template, workspace) : undefined;
const sidebarOption = useTab("sidebar", "");
const sidebarOption = useSearchParamsKey({ key: "sidebar" });
const setSidebarOption = (newOption: string) => {
const { set, value } = sidebarOption;
if (value === newOption) {
set("");
if (sidebarOption.value === newOption) {
sidebarOption.deleteValue();
} else {
set(newOption);
sidebarOption.setValue(newOption);
}
};
@@ -1,7 +1,7 @@
import { useCallback, useEffect } from "react";
import type { WorkspaceResource } from "api/typesGenerated";
import { useTab } from "hooks";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
export const resourceOptionValue = (resource: WorkspaceResource) => {
return `${resource.type}_${resource.name}`;
@@ -14,8 +14,7 @@ export const resourceOptionValue = (resource: WorkspaceResource) => {
// refactoring. Consider revisiting this solution in the future for a more
// robust implementation.
export const useResourcesNav = (resources: WorkspaceResource[]) => {
const resourcesNav = useTab("resources", "");
const resourcesNav = useSearchParamsKey({ key: "resources" });
const isSelected = useCallback(
(resource: WorkspaceResource) => {
return resourceOptionValue(resource) === resourcesNav.value;
@@ -29,8 +28,9 @@ export const useResourcesNav = (resources: WorkspaceResource[]) => {
const hasResources = resources && resources.length > 0;
const hasResourcesWithAgents =
hasResources && resources[0].agents && resources[0].agents.length > 0;
if (!hasSelectedResource && hasResourcesWithAgents) {
resourcesNav.set(resourceOptionValue(resources[0]));
resourcesNav.setValue(resourceOptionValue(resources[0]));
}
},
);
@@ -40,7 +40,7 @@ export const useResourcesNav = (resources: WorkspaceResource[]) => {
const select = useCallback(
(resource: WorkspaceResource) => {
resourcesNav.set(resourceOptionValue(resource));
resourcesNav.setValue(resourceOptionValue(resource));
},
[resourcesNav],
);
+189
View File
@@ -0,0 +1,189 @@
import {
type RenderHookOptions,
type RenderHookResult,
waitFor,
renderHook,
act,
} from "@testing-library/react";
import {
type FC,
type PropsWithChildren,
type ReactNode,
useReducer,
} from "react";
import type { QueryClient } from "react-query";
import {
type Location,
createMemoryRouter,
RouterProvider,
useLocation,
} from "react-router-dom";
import { AppProviders } from "App";
import { RequireAuth } from "contexts/auth/RequireAuth";
import {
type RenderWithAuthOptions,
createTestQueryClient,
} from "./renderHelpers";
export type RouterLocationSnapshot<TLocationState = unknown> = Readonly<{
search: URLSearchParams;
pathname: string;
state: Location<TLocationState>["state"];
}>;
export type GetLocationSnapshot<TLocationState = unknown> =
() => RouterLocationSnapshot<TLocationState>;
export type RenderHookWithAuthResult<
TResult,
TProps,
TLocationState = unknown,
> = Readonly<
Omit<RenderHookResult<TResult, TProps>, "rerender"> & {
queryClient: QueryClient;
rerender: (newProps: TProps) => Promise<void>;
/**
* Gives you back an immutable snapshot of the current location's values.
*
* As this is a snapshot, its values can quickly become inaccurate - as soon
* as a new re-render (even ones you didn't trigger yourself). Keep that in
* mind when making assertions.
*/
getLocationSnapshot: GetLocationSnapshot<TLocationState>;
}
>;
export type RenderHookWithAuthConfig<TProps> = Readonly<{
routingOptions?: Omit<RenderWithAuthOptions, "children">;
renderOptions?: Omit<RenderHookOptions<TProps>, "wrapper">;
}>;
/**
* Gives you a custom version of renderHook that is aware of all our App
* providers (query, routing, etc.).
*
* Unfortunately, React Router does not make it easy to access the router after
* it's been set up, which can lead to some chicken-or-the-egg situations
* @see {@link https://github.com/coder/coder/pull/10362#discussion_r1380852725}
*/
export async function renderHookWithAuth<Result, Props>(
render: (initialProps: Props) => Result,
config: RenderHookWithAuthConfig<Props>,
): Promise<RenderHookWithAuthResult<Result, Props>> {
/**
* Our setup here is evil, gross, and cursed because of how React Router
* itself is set up. We need to go through RouterProvider, so that we have a
* Context for calling all the React Router hooks, but that poses two
* problems:
* 1. <RouterProvider> does not accept children, so there is no easy way to
* interface it with renderHook, which uses children as its only tool for
* dependency injection
* 2. Even after you somehow jam a child value into the router, calling
* renderHook's rerender method will not do anything. RouterProvider is
* auto-memoized against re-renders, so because it thinks that its only
* input (the router object) hasn't changed, it will stop the re-render,
* and prevent any children from re-rendering (even if they would have new
* values).
*
* Have to do a lot of work to side-step those issues (best described as a
* "Super Mario warp pipe"), and make sure that we're not relying on internal
* React Router implementation details that could break at a moment's notice
*/
// Some of the let variables are defined with definite assignment (! operator)
let currentLocation!: Location;
const LocationLeaker: FC<PropsWithChildren> = ({ children }) => {
currentLocation = useLocation();
return <>{children}</>;
};
let forceUpdateRenderHookChildren!: () => void;
let currentRenderHookChildren: ReactNode = undefined;
const InitialRoute: FC = () => {
const [, forceRerender] = useReducer((b: boolean) => !b, false);
forceUpdateRenderHookChildren = () => act(forceRerender);
return <LocationLeaker>{currentRenderHookChildren}</LocationLeaker>;
};
const { routingOptions = {}, renderOptions = {} } = config;
const {
path = "/",
route = "/",
extraRoutes = [],
nonAuthenticatedRoutes = [],
} = routingOptions;
const wrappedExtraRoutes = extraRoutes.map((route) => ({
...route,
element: <LocationLeaker>{route.element}</LocationLeaker>,
}));
const wrappedNonAuthRoutes = nonAuthenticatedRoutes.map((route) => ({
...route,
element: <LocationLeaker>{route.element}</LocationLeaker>,
}));
const router = createMemoryRouter(
[
{
element: <RequireAuth />,
children: [{ path, element: <InitialRoute /> }, ...wrappedExtraRoutes],
},
...wrappedNonAuthRoutes,
],
{ initialEntries: [route], initialIndex: 0 },
);
const queryClient = createTestQueryClient();
const { result, rerender, unmount } = renderHook<Result, Props>(render, {
...renderOptions,
wrapper: ({ children }) => {
currentRenderHookChildren = children;
return (
<AppProviders queryClient={queryClient}>
<RouterProvider router={router} />
</AppProviders>
);
},
});
/**
* This is necessary to get around some providers in AppProviders having
* conditional rendering and not always rendering their children immediately.
*
* renderHook's result won't actually exist until the children defined via its
* wrapper render in full.
*
* Ignore result.current's type signature; it lies to you. This is normally a
* good thing, because the renderHook result will usually evaluate
* synchronously, so by the time you get the result back, you won't have to
* worry about null checks. But because we're setting things up async,
* result.current will be null for at least some period of time
*/
await waitFor(() => expect(result.current).not.toBe(null));
return {
result,
queryClient,
unmount,
rerender: async (newProps) => {
const currentPathname = currentLocation.pathname;
if (currentPathname !== path) {
return;
}
const resultSnapshot = result.current;
rerender(newProps);
forceUpdateRenderHookChildren();
return waitFor(() => expect(result.current).not.toBe(resultSnapshot));
},
getLocationSnapshot: () => {
return {
pathname: currentLocation.pathname,
search: new URLSearchParams(currentLocation.search),
state: currentLocation.state,
};
},
} as const;
}
+5 -79
View File
@@ -1,10 +1,9 @@
import {
render as tlRender,
render as testingLibraryRender,
screen,
waitFor,
renderHook,
} from "@testing-library/react";
import { type ReactNode, useState } from "react";
import type { ReactNode } from "react";
import { QueryClient } from "react-query";
import {
createMemoryRouter,
@@ -19,7 +18,7 @@ import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSetti
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
import { MockUser } from "./entities";
function createTestQueryClient() {
export function createTestQueryClient() {
// Helps create one query client for each test case, to make sure that tests
// are isolated and can't affect each other
return new QueryClient({
@@ -40,7 +39,7 @@ export const renderWithRouter = (
const queryClient = createTestQueryClient();
return {
...tlRender(
...testingLibraryRender(
<AppProviders queryClient={queryClient}>
<RouterProvider router={router} />
</AppProviders>,
@@ -105,79 +104,6 @@ export function renderWithAuth(
};
}
type RenderHookWithAuthOptions<Props> = Partial<
Readonly<
Omit<RenderWithAuthOptions, "children"> & {
initialProps: Props;
}
>
>;
/**
* Custom version of renderHook that is aware of all our App providers.
*
* Had to do some nasty, cursed things in the implementation to make sure that
* the tests using this function remained simple.
*
* @see {@link https://github.com/coder/coder/pull/10362#discussion_r1380852725}
*/
export async function renderHookWithAuth<Result, Props>(
render: (initialProps: Props) => Result,
options: RenderHookWithAuthOptions<Props> = {},
) {
const { initialProps, path = "/", route = "/", extraRoutes = [] } = options;
const queryClient = createTestQueryClient();
// Easy to miss – there's an evil definite assignment via the !
let escapedRouter!: ReturnType<typeof createMemoryRouter>;
const { result, rerender, unmount } = renderHook(render, {
initialProps,
wrapper: ({ children }) => {
/**
* Unfortunately, there isn't a way to define the router outside the
* wrapper while keeping it aware of children, meaning that we need to
* define the router as readonly state in the component instance. This
* ensures the value remains stable across all re-renders
*/
// eslint-disable-next-line react-hooks/rules-of-hooks -- This is actually processed as a component; the linter just isn't aware of that
const [readonlyStatefulRouter] = useState(() => {
return createMemoryRouter(
[{ path, element: <>{children}</> }, ...extraRoutes],
{ initialEntries: [route] },
);
});
/**
* Leaks the wrapper component's state outside React's render cycles.
*/
escapedRouter = readonlyStatefulRouter;
return (
<AppProviders queryClient={queryClient}>
<RouterProvider router={readonlyStatefulRouter} />
</AppProviders>
);
},
});
/**
* This is necessary to get around some providers in AppProviders having
* conditional rendering and not always rendering their children immediately.
*
* The hook result won't actually exist until the children defined via wrapper
* render in full.
*/
await waitFor(() => expect(result.current).not.toBe(null));
return {
result,
rerender,
unmount,
router: escapedRouter,
} as const;
}
export function renderWithTemplateSettingsLayout(
element: JSX.Element,
{
@@ -264,7 +190,7 @@ export const waitForLoaderToBeRemoved = async (): Promise<void> => {
};
export const renderComponent = (component: React.ReactElement) => {
return tlRender(component, {
return testingLibraryRender(component, {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});
};