From ea0673501a57b1ee99c80f2c628760ef53e16cfc Mon Sep 17 00:00:00 2001 From: Sergey Kozlov Date: Thu, 25 Aug 2022 13:13:23 +0300 Subject: [PATCH 01/15] add utils for run tests of hooks; fix usage render from @reach-internal/test instead of @testing-library/react --- internal/test/types.ts | 24 +++++++++++++++++++++++- internal/test/utils.tsx | 22 ++++++++++++++++++++-- package.json | 1 + pnpm-lock.yaml | 35 +++++++++++++++++++++++++++++++++++ test/alias.ts | 1 + 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/internal/test/types.ts b/internal/test/types.ts index a986fd3ec..3608a9296 100644 --- a/internal/test/types.ts +++ b/internal/test/types.ts @@ -3,8 +3,16 @@ import type { RenderOptions as TLRenderOptions, RenderResult as TLRenderResult, } from "@testing-library/react"; +import type { + RenderHookOptions as TLLegacyRenderHookOptions, + RenderHookResult as TLLegacyRenderHookResult, +} from "@testing-library/react-hooks"; +import type { + RenderHookOptions as TLActualRenderHookOptions, + RenderHookResult as TLActualRenderHookResult, +} from "@testing-library/react-13"; -export type RenderOptions = Omit & { +export type RenderOptions = Omit & { strict?: boolean; }; @@ -15,3 +23,17 @@ export type RenderResult< setProps(props: P): RenderResult; forceUpdate(): RenderResult; }; + +export type RenderHookOptions = Omit< + TLLegacyRenderHookOptions & + TLActualRenderHookOptions & { + strict?: boolean; + }, + "wrapper" +>; + +export type RenderHookResult = TLLegacyRenderHookResult< + TProps, + TResult +> & + TLActualRenderHookResult; diff --git a/internal/test/utils.tsx b/internal/test/utils.tsx index 9b5d34252..1c37c7481 100644 --- a/internal/test/utils.tsx +++ b/internal/test/utils.tsx @@ -2,9 +2,15 @@ import * as React from "react"; import { act } from "react-dom/test-utils"; import type { MatcherFunction } from "@testing-library/react"; import { render as tlRender, fireEvent } from "@testing-library/react"; +import { renderHook as tlRenderHook } from "@testing-library/react-hooks"; import { fireEvent as fireDomEvent } from "@testing-library/dom"; import userEvent from "@testing-library/user-event"; -import type { RenderOptions, RenderResult } from "./types"; +import type { + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from "./types"; /** * This function is useful if you want to query a DOM element by its text @@ -79,6 +85,17 @@ export function render< return result; } +export function renderHook( + callback: (props: TProps) => TResult, + options: RenderHookOptions = {} +): RenderHookResult { + const { strict = false, ...restOptions } = options; + return tlRenderHook(callback, { + ...restOptions, + wrapper: strict ? React.StrictMode : React.Fragment, + }); +} + export async function wait(time: number) { return await new Promise((res) => setTimeout(res, time)); } @@ -125,6 +142,7 @@ export function simulateEnterKeyClick( type Query = (f: MatcherFunction) => HTMLElement | null; -export * from "@testing-library/react"; +export { cleanup as cleanupHooks } from "@testing-library/react-hooks"; +export { cleanup, fireEvent, screen } from "@testing-library/react"; export { act, userEvent, fireDomEvent }; export type { RenderOptions, RenderResult }; diff --git a/package.json b/package.json index 6f4ae9d1d..ab02f27f9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@testing-library/dom": "^8.16.0", "@testing-library/react": "^12.1.5", "@testing-library/react-13": "npm:@testing-library/react@^13.3.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.2.1", "@types/aria-query": "^5.0.0", "@types/css": "^0.0.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b7bbe496..73653ff21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,7 @@ importers: '@testing-library/dom': ^8.16.0 '@testing-library/react': ^12.1.5 '@testing-library/react-13': npm:@testing-library/react@^13.3.0 + '@testing-library/react-hooks': ^8.0.1 '@testing-library/user-event': ^14.2.1 '@types/aria-query': ^5.0.0 '@types/css': ^0.0.33 @@ -109,6 +110,7 @@ importers: '@testing-library/dom': 8.16.0 '@testing-library/react': 12.1.5_sfoxds7t5ydpegc3knd667wn6m '@testing-library/react-13': /@testing-library/react/13.3.0_sfoxds7t5ydpegc3knd667wn6m + '@testing-library/react-hooks': 8.0.1_nn45z5sr7igu7sfun6tiae5hx4 '@testing-library/user-event': 14.2.1_gwcpuyfvwbszhlmedmugzivgzu '@types/aria-query': 5.0.0 '@types/css': 0.0.33 @@ -3658,6 +3660,29 @@ packages: pretty-format: 27.5.1 dev: false + /@testing-library/react-hooks/8.0.1_nn45z5sr7igu7sfun6tiae5hx4: + resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} + engines: {node: '>=12'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + dependencies: + '@babel/runtime': 7.18.6 + '@types/react': 17.0.47 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + react-error-boundary: 3.1.4_react@17.0.2 + dev: false + /@testing-library/react/12.1.5_sfoxds7t5ydpegc3knd667wn6m: resolution: {integrity: sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==} engines: {node: '>=12'} @@ -10723,6 +10748,16 @@ packages: react-is: 17.0.2 dev: false + /react-error-boundary/3.1.4_react@17.0.2: + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.18.6 + react: 17.0.2 + dev: false + /react-focus-lock/2.5.2_react@17.0.2: resolution: {integrity: sha512-WzpdOnEqjf+/A3EH9opMZWauag7gV0BxFl+EY4ElA4qFqYsUsBLnmo2sELbN5OC30S16GAWMy16B9DLPpdJKAQ==} peerDependencies: diff --git a/test/alias.ts b/test/alias.ts index 84660132f..e84c2742f 100644 --- a/test/alias.ts +++ b/test/alias.ts @@ -14,6 +14,7 @@ if (reactVersion === 16) { "react-dom": "react-dom-18", "react-is": "react-is-18", "@testing-library/react": "@testing-library/react-13", + "@testing-library/react-hooks": "@testing-library/react-13", }; } From fd852dbe8f61cb6d1eed2a10f62335780a6e402a Mon Sep 17 00:00:00 2001 From: Sergey Kozlov Date: Thu, 25 Aug 2022 13:30:45 +0300 Subject: [PATCH 02/15] add tests for useConstant --- packages/utils/__tests__/use-constant.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 packages/utils/__tests__/use-constant.test.ts diff --git a/packages/utils/__tests__/use-constant.test.ts b/packages/utils/__tests__/use-constant.test.ts new file mode 100644 index 000000000..727ea96e2 --- /dev/null +++ b/packages/utils/__tests__/use-constant.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { renderHook, cleanupHooks } from "@reach-internal/test/utils"; +import { useConstant } from "@reach/utils"; + +afterEach(cleanupHooks); + +describe("useConstant", () => { + const renderUseConstant = () => + renderHook(() => useConstant(() => ({ foo: "bar" }))); + + it("should return value from callback", () => { + const render = renderUseConstant(); + + const firstRenderedObject = render.result.current; + expect(firstRenderedObject).toEqual({ foo: "bar" }); + }); + + it("should return the same value after rerender", () => { + const render = renderUseConstant(); + const resultFirst = render.result.current; + render.rerender(); + const resultSecond = render.result.current; + + expect(resultFirst).toBe(resultSecond); + }); +}); From d58d027fac7cbb7e3f75d0be20d65ae1549863c9 Mon Sep 17 00:00:00 2001 From: Sergey Kozlov Date: Thu, 25 Aug 2022 16:12:08 +0300 Subject: [PATCH 03/15] add tests for useControlledState --- internal/test/utils.tsx | 12 ++--- .../__tests__/use-controlled-state.test.ts | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 packages/utils/__tests__/use-controlled-state.test.ts diff --git a/internal/test/utils.tsx b/internal/test/utils.tsx index 1c37c7481..836e9c9ef 100644 --- a/internal/test/utils.tsx +++ b/internal/test/utils.tsx @@ -1,10 +1,7 @@ import * as React from "react"; -import { act } from "react-dom/test-utils"; import type { MatcherFunction } from "@testing-library/react"; import { render as tlRender, fireEvent } from "@testing-library/react"; import { renderHook as tlRenderHook } from "@testing-library/react-hooks"; -import { fireEvent as fireDomEvent } from "@testing-library/dom"; -import userEvent from "@testing-library/user-event"; import type { RenderHookOptions, RenderHookResult, @@ -142,7 +139,10 @@ export function simulateEnterKeyClick( type Query = (f: MatcherFunction) => HTMLElement | null; -export { cleanup as cleanupHooks } from "@testing-library/react-hooks"; -export { cleanup, fireEvent, screen } from "@testing-library/react"; -export { act, userEvent, fireDomEvent }; +export { + cleanup as cleanupHooks, + act as actHooks, +} from "@testing-library/react-hooks"; +export { cleanup, fireEvent, screen, act } from "@testing-library/react"; +export * as userEvent from "@testing-library/user-event"; export type { RenderOptions, RenderResult }; diff --git a/packages/utils/__tests__/use-controlled-state.test.ts b/packages/utils/__tests__/use-controlled-state.test.ts new file mode 100644 index 000000000..9cc2c11d8 --- /dev/null +++ b/packages/utils/__tests__/use-controlled-state.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { renderHook, cleanupHooks, actHooks } from "@reach-internal/test/utils"; +import { useControlledState } from "@reach/utils"; + +afterEach(cleanupHooks); + +describe("useControlledState", () => { + const DEFAULT_VALUE = 10; + const CONTROLLED_VALUE = 42; + + it("should return value and setter", () => { + const { result } = renderHook(() => + useControlledState({ + defaultValue: DEFAULT_VALUE, + controlledValue: undefined, + }) + ); + + expect(result.current[0]).toBe(DEFAULT_VALUE); + expect(typeof result.current[1]).toBe("function"); + }); + + it("should work as uncontrolled", () => { + const { result } = renderHook(() => + useControlledState({ + defaultValue: DEFAULT_VALUE, + controlledValue: undefined, + }) + ); + expect(result.current[0]).toBe(DEFAULT_VALUE); + actHooks(() => { + result.current[1](17); + }); + expect(result.current[0]).toBe(17); + }); + + it("should work as controlled", () => { + const { result } = renderHook(() => + useControlledState({ + defaultValue: DEFAULT_VALUE, + controlledValue: CONTROLLED_VALUE, + }) + ); + expect(result.current[0]).toBe(CONTROLLED_VALUE); + actHooks(() => { + result.current[1](17); + }); + expect(result.current[0]).toBe(CONTROLLED_VALUE); + }); +}); From e62e04f247bb119855c7ef46f6e6f31a49efbf39 Mon Sep 17 00:00:00 2001 From: Sergey Kozlov Date: Thu, 25 Aug 2022 17:10:29 +0300 Subject: [PATCH 04/15] add tests for useEventListener --- .../__tests__/use-event-listener.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/utils/__tests__/use-event-listener.test.tsx diff --git a/packages/utils/__tests__/use-event-listener.test.tsx b/packages/utils/__tests__/use-event-listener.test.tsx new file mode 100644 index 000000000..c81fcf398 --- /dev/null +++ b/packages/utils/__tests__/use-event-listener.test.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { render, fireEvent, cleanup } from "@reach-internal/test/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useEventListener } from "@reach/utils"; + +afterEach(cleanup); + +describe("useEventListener", () => { + const Test = ({ onBodyClick }: { onBodyClick: () => void }) => { + useEventListener("click", onBodyClick, document.body); + return null; + }; + + it("should call event listener when it's need", () => { + const handleBodyClick = vi.fn(); + render(); + fireEvent.click(document.body); + expect(handleBodyClick).toHaveBeenCalledTimes(1); + fireEvent.click(document.body); + expect(handleBodyClick).toHaveBeenCalledTimes(2); + }); + + it("should can change event listener from args", () => { + const handleBodyClick1 = vi.fn(); + const handleBodyClick2 = vi.fn(); + const { rerender } = render(); + fireEvent.click(document.body); + rerender(); + fireEvent.click(document.body); + expect(handleBodyClick1).toHaveBeenCalledOnce(); + expect(handleBodyClick2).toHaveBeenCalledOnce(); + }); +}); From 030ce698586728882f4cb115c5cd0276eabfa716 Mon Sep 17 00:00:00 2001 From: Sergey Kozlov Date: Thu, 25 Aug 2022 18:40:15 +0300 Subject: [PATCH 05/15] add tests for useFocusChange and fix errors --- internal/test/utils.tsx | 2 +- .../utils/__tests__/use-focus-change.test.tsx | 106 ++++++++++++++++++ packages/utils/src/use-focus-change.ts | 20 ++-- 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 packages/utils/__tests__/use-focus-change.test.tsx diff --git a/internal/test/utils.tsx b/internal/test/utils.tsx index 836e9c9ef..4e9a975de 100644 --- a/internal/test/utils.tsx +++ b/internal/test/utils.tsx @@ -144,5 +144,5 @@ export { act as actHooks, } from "@testing-library/react-hooks"; export { cleanup, fireEvent, screen, act } from "@testing-library/react"; -export * as userEvent from "@testing-library/user-event"; +export { default as userEvent } from "@testing-library/user-event"; export type { RenderOptions, RenderResult }; diff --git a/packages/utils/__tests__/use-focus-change.test.tsx b/packages/utils/__tests__/use-focus-change.test.tsx new file mode 100644 index 000000000..5b75ab2b3 --- /dev/null +++ b/packages/utils/__tests__/use-focus-change.test.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import { render, cleanup, userEvent } from "@reach-internal/test/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useFocusChange } from "@reach/utils"; + +afterEach(cleanup); + +describe("useFocusChange", () => { + const Test = ({ + onChange, + when, + }: { + onChange: () => void; + when?: "focus" | "blur"; + }) => { + useFocusChange(onChange, when); + return ( + <> + + +
just div
+ + ); + }; + + const renderTest = (when?: "focus" | "blur") => { + const handleChange = vi.fn(); + const { getByPlaceholderText, getByText } = render( + + ); + const firstInput = getByPlaceholderText("first"); + const secondInput = getByPlaceholderText("second"); + const div = getByText("just div"); + return { + firstInput, + secondInput, + div, + handleChange, + }; + }; + + /** + * WARNING: The order of the tests is important: + * the blur test should come first. + * If this is not the case, the activeElement will be dirty + * and the blur event will fire when the input is clicked. + */ + + it("should call handler on blur", async () => { + const { + firstInput, + secondInput, + div, + handleChange: handleBlur, + } = renderTest("blur"); + + await userEvent.click(firstInput); + expect(handleBlur).not.toHaveBeenCalled(); + + await userEvent.click(secondInput); + expect(handleBlur).toHaveBeenCalledTimes(1); + expect(handleBlur).toHaveBeenCalledWith( + document.body, + document.body, + expect.any(FocusEvent) + ); + + await userEvent.click(div); + expect(handleBlur).toHaveBeenCalledTimes(2); + expect(handleBlur).toHaveBeenCalledWith( + document.body, + document.body, + expect.any(FocusEvent) + ); + }); + + it("should call handler on focus", async () => { + const { firstInput, secondInput, handleChange: handleFocus } = renderTest(); + + await userEvent.click(firstInput); + expect(handleFocus).toHaveBeenCalledTimes(1); + expect(handleFocus).toHaveBeenCalledWith( + firstInput, + document.body, + expect.any(FocusEvent) + ); + + await userEvent.click(secondInput); + expect(handleFocus).toHaveBeenCalledTimes(2); + expect(handleFocus).toHaveBeenCalledWith( + secondInput, + firstInput, + expect.any(FocusEvent) + ); + }); + + it("should do not call handler on focus at the same node", async () => { + const { firstInput, handleChange: handleFocus } = renderTest(); + + await userEvent.click(firstInput); + expect(handleFocus).toHaveBeenCalledOnce(); + + await userEvent.click(firstInput); + expect(handleFocus).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/utils/src/use-focus-change.ts b/packages/utils/src/use-focus-change.ts index 86da9057a..e0944bb05 100644 --- a/packages/utils/src/use-focus-change.ts +++ b/packages/utils/src/use-focus-change.ts @@ -22,20 +22,24 @@ export function useFocusChange( lastActiveElement.current = ownerDocument.activeElement; function onChange(event: FocusEvent) { - if (lastActiveElement.current !== ownerDocument.activeElement) { - handleChange( - ownerDocument.activeElement, - lastActiveElement.current, - event - ); - lastActiveElement.current = ownerDocument.activeElement; + if ( + when === "focus" && + lastActiveElement.current === ownerDocument.activeElement + ) { + return; } + handleChange( + ownerDocument.activeElement, + lastActiveElement.current, + event + ); + lastActiveElement.current = ownerDocument.activeElement; } ownerDocument.addEventListener(when, onChange, true); return () => { - ownerDocument.removeEventListener(when, onChange); + ownerDocument.removeEventListener(when, onChange, true); }; }, [when, handleChange, ownerDocument]); } From 711118009a2cc1036dfbd406bbf7c84505ffc599 Mon Sep 17 00:00:00 2001 From: Sergey Kozlov Date: Thu, 25 Aug 2022 19:35:48 +0300 Subject: [PATCH 06/15] add tests for useForceUpdate --- .../utils/__tests__/use-force-update.test.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/utils/__tests__/use-force-update.test.tsx diff --git a/packages/utils/__tests__/use-force-update.test.tsx b/packages/utils/__tests__/use-force-update.test.tsx new file mode 100644 index 000000000..7aa7c1db7 --- /dev/null +++ b/packages/utils/__tests__/use-force-update.test.tsx @@ -0,0 +1,34 @@ +/// + +import * as React from "react"; +import { render, cleanup, userEvent } from "@reach-internal/test/utils"; +import { afterEach, describe, expect, it } from "vitest"; +import { useForceUpdate } from "@reach/utils"; + +afterEach(cleanup); + +describe("useForceUpdate", () => { + it("should force rerender when called", async () => { + let nonObservableVariable = "foo"; + + const Test = () => { + const forceUpdate = useForceUpdate(); + return ( + <> +
{nonObservableVariable}
+