diff --git a/.github/actions/setup-deps-react-18/action.yml b/.github/actions/setup-deps-react-18/action.yml new file mode 100644 index 00000000..a6e028f8 --- /dev/null +++ b/.github/actions/setup-deps-react-18/action.yml @@ -0,0 +1,32 @@ +name: Setup deps (React 18) +description: Setup Node.js and install dependencies + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + + - name: Cache deps + id: yarn-cache + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ./node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('**/package.json', '!node_modules/**') }} + restore-keys: | + ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + ${{ runner.os }}-yarn- + + - name: Install deps + if: steps.yarn-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + shell: bash + + - name: Switch to React 18 + run: | + yarn add -D react@18.3.1 react-test-renderer@18.3.1 @types/react@18.3.1 @types/react-test-renderer@18.3.1 react-native@0.77.0 @react-native/babel-preset@0.77.0 + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c512a81..feb8c625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,19 @@ jobs: - name: Typecheck run: yarn typecheck + typecheck-react-18: + runs-on: ubuntu-latest + name: Typecheck React 18 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps-react-18 + + - name: Typecheck + run: yarn typecheck + test: runs-on: ubuntu-latest name: Test @@ -66,12 +79,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js and deps - uses: ./.github/actions/setup-deps - - - name: Switch to React 18 - run: | - yarn remove react react-test-renderer react-native @react-native/babel-preset - yarn add -D react@18.3.1 react-test-renderer@18.3.1 react-native@0.77.0 @react-native/babel-preset@0.77.0 + uses: ./.github/actions/setup-deps-react-18 - name: Test run: yarn test:ci diff --git a/src/__tests__/render-hook-async.test.tsx b/src/__tests__/render-hook-async.test.tsx index a9f86212..8cc398ee 100644 --- a/src/__tests__/render-hook-async.test.tsx +++ b/src/__tests__/render-hook-async.test.tsx @@ -13,6 +13,12 @@ afterEach(() => { console.error = originalConsoleError; }); +function useSuspendingHook(promise: Promise) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: React 18 does not have `use` hook + return React.use(promise); +} + test('renderHookAsync renders hook asynchronously', async () => { const { result } = await renderHookAsync(() => { const [state, setState] = React.useState(1); @@ -124,10 +130,6 @@ test('handles multiple state updates in effects', async () => { }); testGateReact19('handles hook with suspense', async () => { - function useSuspendingHook(promise: Promise) { - return React.use(promise); - } - let resolvePromise: (value: string) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; @@ -169,10 +171,6 @@ testGateReact19('handles hook suspense with error boundary', async () => { // eslint-disable-next-line no-console console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); - function useSuspendingHook(promise: Promise) { - return React.use(promise); - } - let rejectPromise: (error: Error) => void; const promise = new Promise((_resolve, reject) => { rejectPromise = reject; diff --git a/src/__tests__/render-hook.test.tsx b/src/__tests__/render-hook.test.tsx index 85151fdf..9cdc6618 100644 --- a/src/__tests__/render-hook.test.tsx +++ b/src/__tests__/render-hook.test.tsx @@ -60,20 +60,18 @@ test('allows wrapper components', () => { expect(result.current).toEqual('provided'); }); -const useMyHook = (param: number | undefined) => { - return param; -}; +function useMyHook(param: T) { + return { param }; +} test('props type is inferred correctly when initial props is defined', () => { - const { result, rerender } = renderHook((num: number | undefined) => useMyHook(num), { + const { result, rerender } = renderHook((num: number) => useMyHook(num), { initialProps: 5, }); - - expect(result.current).toBe(5); + expect(result.current.param).toBe(5); rerender(6); - - expect(result.current).toBe(6); + expect(result.current.param).toBe(6); }); test('props type is inferred correctly when initial props is explicitly undefined', () => { @@ -81,11 +79,10 @@ test('props type is inferred correctly when initial props is explicitly undefine initialProps: undefined, }); - expect(result.current).toBeUndefined(); + expect(result.current.param).toBeUndefined(); rerender(6); - - expect(result.current).toBe(6); + expect(result.current.param).toBe(6); }); /** diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index ffa4ef8b..a3ac0c07 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -16,6 +16,8 @@ afterEach(() => { }); function Suspending({ promise, testID }: { promise: Promise; testID: string }) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore React 18 does not have `use` hook React.use(promise); return ; } diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index a22ba1ed..ac794c7a 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -14,6 +14,8 @@ afterEach(() => { }); function Suspending({ promise, testID }: { promise: Promise; testID: string }) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore React 18 does not have `use` hook React.use(promise); return ; } diff --git a/src/render-hook.tsx b/src/render-hook.tsx index 63f59c92..7566cc61 100644 --- a/src/render-hook.tsx +++ b/src/render-hook.tsx @@ -2,15 +2,16 @@ import * as React from 'react'; import render from './render'; import renderAsync from './render-async'; +import type { RefObject } from './types'; export type RenderHookResult = { - result: React.RefObject; + result: RefObject; rerender: (props: Props) => void; unmount: () => void; }; export type RenderHookAsyncResult = { - result: React.RefObject; + result: RefObject; rerenderAsync: (props: Props) => Promise; unmountAsync: () => Promise; }; @@ -39,7 +40,7 @@ export function renderHook( hookToRender: (props: Props) => Result, options?: RenderHookOptions, ): RenderHookResult { - const result: React.RefObject = React.createRef(); + const result = React.createRef() as RefObject; function HookContainer({ hookProps }: { hookProps: Props }) { const renderResult = hookToRender(hookProps); @@ -58,8 +59,7 @@ export function renderHook( ); return { - // Result should already be set after the first render effects are run. - result: result as React.RefObject, + result: result, rerender: (hookProps: Props) => rerenderComponent(), unmount, }; @@ -69,7 +69,7 @@ export async function renderHookAsync( hookToRender: (props: Props) => Result, options?: RenderHookOptions, ): Promise> { - const result: React.RefObject = React.createRef(); + const result = React.createRef() as RefObject; function TestComponent({ hookProps }: { hookProps: Props }) { const renderResult = hookToRender(hookProps); @@ -88,8 +88,7 @@ export async function renderHookAsync( ); return { - // Result should already be set after the first render effects are run. - result: result as React.RefObject, + result: result, rerenderAsync: (hookProps: Props) => rerenderComponentAsync(), unmountAsync, diff --git a/src/types.ts b/src/types.ts index 293e7e81..8da61033 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,8 @@ +/** `RefObject` type from React 19. */ +export type RefObject = { + current: T; +}; + /** * Location of an element. */