Skip to content

fix: fix render hook typing #1811

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 5 commits into from
Aug 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/actions/setup-deps-react-18/action.yml
Original file line number Diff line number Diff line change
@@ -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 [email protected] [email protected] @types/[email protected] @types/[email protected] [email protected] @react-native/[email protected]
shell: bash
20 changes: 14 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 [email protected] [email protected] [email protected] @react-native/[email protected]
uses: ./.github/actions/setup-deps-react-18

- name: Test
run: yarn test:ci
14 changes: 6 additions & 8 deletions src/__tests__/render-hook-async.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ afterEach(() => {
console.error = originalConsoleError;
});

function useSuspendingHook(promise: Promise<string>) {
// 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);
Expand Down Expand Up @@ -124,10 +130,6 @@ test('handles multiple state updates in effects', async () => {
});

testGateReact19('handles hook with suspense', async () => {
function useSuspendingHook(promise: Promise<string>) {
return React.use(promise);
}

let resolvePromise: (value: string) => void;
const promise = new Promise<string>((resolve) => {
resolvePromise = resolve;
Expand Down Expand Up @@ -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<string>) {
return React.use(promise);
}

let rejectPromise: (error: Error) => void;
const promise = new Promise<string>((_resolve, reject) => {
rejectPromise = reject;
Expand Down
19 changes: 8 additions & 11 deletions src/__tests__/render-hook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,32 +60,29 @@ test('allows wrapper components', () => {
expect(result.current).toEqual('provided');
});

const useMyHook = (param: number | undefined) => {
return param;
};
function useMyHook<T>(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', () => {
const { result, rerender } = renderHook((num: number | undefined) => useMyHook(num), {
initialProps: undefined,
});

expect(result.current).toBeUndefined();
expect(result.current.param).toBeUndefined();

rerender(6);

expect(result.current).toBe(6);
expect(result.current.param).toBe(6);
});

/**
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/suspense-fake-timers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ afterEach(() => {
});

function Suspending({ promise, testID }: { promise: Promise<unknown>; testID: string }) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore React 18 does not have `use` hook
React.use(promise);
return <View testID={testID} />;
}
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/suspense.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ afterEach(() => {
});

function Suspending({ promise, testID }: { promise: Promise<unknown>; testID: string }) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore React 18 does not have `use` hook
React.use(promise);
return <View testID={testID} />;
}
Expand Down
15 changes: 7 additions & 8 deletions src/render-hook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, Props> = {
result: React.RefObject<Result>;
result: RefObject<Result>;
rerender: (props: Props) => void;
unmount: () => void;
};

export type RenderHookAsyncResult<Result, Props> = {
result: React.RefObject<Result>;
result: RefObject<Result>;
rerenderAsync: (props: Props) => Promise<void>;
unmountAsync: () => Promise<void>;
};
Expand Down Expand Up @@ -39,7 +40,7 @@ export function renderHook<Result, Props>(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props> {
const result: React.RefObject<Result | null> = React.createRef();
const result = React.createRef<Result>() as RefObject<Result>;

function HookContainer({ hookProps }: { hookProps: Props }) {
const renderResult = hookToRender(hookProps);
Expand All @@ -58,8 +59,7 @@ export function renderHook<Result, Props>(
);

return {
// Result should already be set after the first render effects are run.
result: result as React.RefObject<Result>,
result: result,
rerender: (hookProps: Props) => rerenderComponent(<HookContainer hookProps={hookProps} />),
unmount,
};
Expand All @@ -69,7 +69,7 @@ export async function renderHookAsync<Result, Props>(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions<Props>,
): Promise<RenderHookAsyncResult<Result, Props>> {
const result: React.RefObject<Result | null> = React.createRef();
const result = React.createRef<Result>() as RefObject<Result>;

function TestComponent({ hookProps }: { hookProps: Props }) {
const renderResult = hookToRender(hookProps);
Expand All @@ -88,8 +88,7 @@ export async function renderHookAsync<Result, Props>(
);

return {
// Result should already be set after the first render effects are run.
result: result as React.RefObject<Result>,
result: result,
rerenderAsync: (hookProps: Props) =>
rerenderComponentAsync(<TestComponent hookProps={hookProps} />),
unmountAsync,
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/** `RefObject` type from React 19. */
export type RefObject<T> = {
current: T;
};

/**
* Location of an element.
*/
Expand Down