-
Notifications
You must be signed in to change notification settings - Fork 881
chore(site): update and refactor all custom hook tests that rely on React Router #12219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
0b186bf
chore: rename useTab to useSearchParamsKey and add test
Parkreiner a7a0944
chore: mark old renderHookWithAuth as deprecated (temp)
Parkreiner ec3ba4f
fix: update imports for useResourcesNav
Parkreiner 6d7ef9f
Merge branch 'main' into mes/hook-test-revamps-4
Parkreiner afffb26
refactor: change API for useSearchParamsKey
Parkreiner 590f02b
chore: let user pass in their own URLSearchParams value
Parkreiner 9e00ea6
refactor: clean up comments for clarity
Parkreiner d7110c5
fix: update import
Parkreiner 2cc63d7
wip: commit progress on useWorkspaceDuplication revamp
Parkreiner 26089e3
chore: migrate duplication test to new helper
Parkreiner 8057397
refactor: update code for clarity
Parkreiner f8f87a3
refactor: reorder test cases for clarity
Parkreiner dcc89dc
refactor: split off hook helper into separate file
Parkreiner 1ab6f03
refactor: remove reliance on internal React Router state property
Parkreiner b32603f
refactor: move variables around for more clarity
Parkreiner b0fabde
refactor: more updates for clarity
Parkreiner b94decc
refactor: reorganize test cases for clarity
Parkreiner 3e41877
refactor: clean up test cases for useWorkspaceDupe
Parkreiner 1fcf9e2
refactor: clean up test cases for useWorkspaceDupe
Parkreiner 13f5e53
Merge branch 'main' into mes/hook-test-revamps-4
Parkreiner File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
refactor: split off hook helper into separate file
- Loading branch information
commit dcc89dc165cadb32cd9005f607bd4beea876b8ab
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
import { | ||
type RenderHookOptions, | ||
type RenderHookResult, | ||
waitFor, | ||
renderHook, | ||
act, | ||
} from "@testing-library/react"; | ||
import { type ReactNode, useState } from "react"; | ||
import { QueryClient } from "react-query"; | ||
import { AppProviders } from "App"; | ||
import { RequireAuth } from "contexts/auth/RequireAuth"; | ||
import { | ||
type RenderWithAuthOptions, | ||
createTestQueryClient, | ||
} from "./renderHelpers"; | ||
import { | ||
type Location, | ||
createMemoryRouter, | ||
RouterProvider, | ||
} from "react-router-dom"; | ||
|
||
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>> { | ||
const { routingOptions = {}, renderOptions = {} } = config; | ||
const { | ||
path = "/", | ||
route = "/", | ||
extraRoutes = [], | ||
nonAuthenticatedRoutes = [], | ||
} = routingOptions; | ||
|
||
/** | ||
* 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). | ||
* | ||
* The current solution side-steps that with a Rube Goldberg approach: | ||
* 1. When renderHook's wrapper renders/re-renders, store its children value | ||
* (the JSX for the shell component it uses to expose hook values) in a | ||
* separate variable | ||
* 2. Create a RenderHookEscapeHatch component that has two jobs: | ||
* 1. Read from the most recent child value escaped from the wrapper | ||
* 2. Expose a function for manually triggering re-renders for | ||
* RenderHookEscapeHatch only (parent components will be unaffected) | ||
* 3. When we call renderHook for the mounting render, RouterProvider will | ||
* be getting initialized, so it will let the render go through | ||
* 1. We eject the children value outside the wrapper and catch it with the | ||
* variable. | ||
* 2. RenderHookEscapeHatch renders and uses the variable for its output, | ||
* and exposes the forced re-render helper | ||
* 3. We eventually get our full JSX output, and React DOM turns that into | ||
* some UI values | ||
* 4. If we have no need for manual re-renders, we're done at this point. Any | ||
* re-renders done via the functions on the custom hook you're trying to | ||
* test will have no problems, and will not have re-render issues | ||
* | ||
* But if we re-render manually via renderHook's rerender function: | ||
* 1. We grab a copy of the reference to the result.current property | ||
* 2. We trigger a re-render via renderHook to make sure that we're exposing | ||
* the new re-render props to the variable | ||
* 3. RouterProvider will block the re-render, so RenderHookEscapeHatch does | ||
* not produce a new value | ||
* 4. We call the force re-render helper to make RenderHookEscapeHatch | ||
* re-render as a child node. | ||
* 5. It reads from the variable, so it will inject the most up to date | ||
* version of the renderHook shell component JSX into its output | ||
* 6. Just to be on the safe side, we wait for result.current not to equal | ||
* the snapshot we grabbed (even if the inner values are the same, the | ||
* reference values will be different), resolving that via a promise | ||
*/ | ||
// Easy to miss - definite assignment via ! | ||
let forceChildRerender!: () => void; | ||
let currentRenderHookChildren: ReactNode = undefined; | ||
|
||
const RenderHookEscapeHatch = () => { | ||
const [, setThrowawayRenderValue] = useState(false); | ||
forceChildRerender = () => { | ||
act(() => setThrowawayRenderValue((current) => !current)); | ||
}; | ||
|
||
return <>{currentRenderHookChildren}</>; | ||
}; | ||
|
||
const router = createMemoryRouter( | ||
[ | ||
{ | ||
element: <RequireAuth />, | ||
children: [ | ||
{ path, element: <RenderHookEscapeHatch /> }, | ||
...extraRoutes, | ||
], | ||
}, | ||
...nonAuthenticatedRoutes, | ||
], | ||
{ | ||
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, which is normally a good thing (no needless null checks), but not here | ||
*/ | ||
await waitFor(() => expect(result.current).not.toBe(null)); | ||
|
||
return { | ||
result, | ||
queryClient, | ||
unmount, | ||
rerender: (newProps) => { | ||
const currentValue = result.current; | ||
rerender(newProps); | ||
forceChildRerender(); | ||
return waitFor(() => expect(result.current).not.toBe(currentValue)); | ||
}, | ||
getLocationSnapshot: () => { | ||
const location = router.state.location; | ||
return { | ||
pathname: location.pathname, | ||
search: new URLSearchParams(location.search), | ||
state: location.state, | ||
}; | ||
}, | ||
} as const; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this really isn't supposed to be an
await
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo I forgot to fix – good catch