Skip to content

Unable to test components that suspend when using React 19 #1787

Closed

Description

@winghouchan

Describe the bug

When using React 19 and testing a component that suspends, the component never reaches its un-suspended state and the test fails with the following error:

  console.error
    A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React components that depend on asynchronous data, you must await the result:
    
    await act(() => ...)

      at node_modules/react/cjs/react.development.js:1157:23

Expected behavior

The component should be able to un-suspend, allowing the test to run properly and pass.

Steps to Reproduce

After cloning the reproduction repository (a fork with a test case added) and installing dependencies, run the tests (yarn run test).

Relevant code (diff with upstream):

import * as React from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';

// ...

import { fireEvent, render, screen } from '..';

// ...

test('supports components which can suspend', async () => {
  function wait(delay: number) {
    return new Promise<void>((resolve) => setTimeout(() => resolve(), delay));
  }

  function Suspendable<T>({ promise }: { promise: Promise<T> }) {
    React.use(promise);
    return <View testID="test" />;
  }

  function Fallback() {
    return <View testID="fallback" />;
  }

  render(
    <View>
      <React.Suspense fallback={<Fallback />}>
        <Suspendable promise={wait(1000)} />
      </React.Suspense>
    </View>,
  );

  expect(screen.getByTestId('fallback')).toBeOnTheScreen();
  expect(await screen.findByTestId('test')).toBeOnTheScreen();
});

Versions

@testing-library/react-native: ^13.2.0 => 13.2.0 
react: 19.0.0 => 19.0.0 
react-native: ^0.79.0 => 0.79.5 
react-test-renderer: 19.0.0 => 19.0.0 

Activity

  1. winghouchan commented on Jul 11, 2025

    @winghouchan
    Author

    Additional notes

    For context, the actual issue the MCVE reproduces was encountered during an upgrade of Expo from 52 to 53, which upgrades React from 18 to 19. Tests passed with React 18 but did not with React 19.

    In case they are significant, here are some notes regarding the similarities and differences between the actual issue the MCVE reproduces and the MCVE.

    Similarities:

    • The tests fail.
    • The render tree shows the Suspense fallback – the component is never rendered in its un-suspended state.

    Differences:

    • In the actual issue, the components under test did not use use, instead they used a Suspense-enabled library (React Query). The MCVE does not use React Query to keep the reproduction as minimal as possible.
    • In the actual issue, tests did not call render from @testing-library/react-native directly – they called renderRouter from expo-router/testing-library which under-the-hood calls render from @testing-library/react-native. The MCVE does not use Expo Router to keep the reproduction as minimal as possible.
    • In the actual issue, the error "a component suspended inside an act scope, but the act call was not awaited" is never logged. No errors are thrown (only test expectations fail). This point could mean this MCVE doesn't reproduce the actual issue however the similarities are close enough.
  2. winghouchan commented on Jul 11, 2025

    @winghouchan
    Author
  3. winghouchan commented on Jul 11, 2025

    @winghouchan
    Author

    A contributor to React Testing Library gave the following workaround (comment):

    In the meantime, you can unblock yourselves by wrapping the updates that suspend in await act(async () => { ... }). The passed callback to act must be async in those cases.

    However, in React Native Testing Library, it causes a different error:

    Can't access .root on unmounted test renderer

  4. winghouchan commented on Jul 11, 2025

    @winghouchan
    Author

    In regards to the last point of the additional notes:

    • In the actual issue, the error "a component suspended inside an act scope, but the act call was not awaited" is never logged. No errors are thrown (only test expectations fail). This point could mean this MCVE doesn't reproduce the actual issue however the similarities are close enough.

    I've added an additional example using Expo Router Testing Library. It's on the with-expo branch (diff with main).

  5. winghouchan commented on Jul 11, 2025

    @winghouchan
    Author

    The difference renderRouter from Expo Router Testing Library brings seems to be calling jest.useFakeTimers before render (source). In the example on main, adding a call to jest.useFakeTimers before render stops the error ("a component suspended inside an act scope, but the act call was not awaited") from being thrown (but the test still fails).

  6. mdjastrzebski commented on Jul 11, 2025

    @mdjastrzebski
    Member

    @winghouchan in your repro repo, would using await findByTestId instead of getByTestId would make the test pass?

  7. winghouchan commented on Jul 11, 2025

    @winghouchan
    Author

    @mdjastrzebski: no, that made no difference, the same things were experienced.

    Code in the repo (commit) and the original report have been updated.

  8. winghouchan commented on Jul 11, 2025

    @winghouchan
    Author

    Note

    The reproduction has been updated to a test case added to the library's tests. See diff between the fork and the upstream.

  9. winghouchan commented on Jul 12, 2025

    @winghouchan
    Author

    Hmm, I seem to have been misunderstanding React.use. From adding logs, it looks like Suspendable was calling the async function on every render which causes the component to suspend indefinitely because the Promise gets re-created. React.use under-the-hood does not keep a stable reference to the Promise. I've (now) noticed the documentation also states this:

    Promises created in Client Components are recreated on every render.

    – use Caveats, React Documentation

    Interestingly, putting the Promise in state (example below) does not seem to yield the desired effect. wait still seems to be called on every render.

      function Suspendable() {
        const [promise] = React.useState(() => wait(1000));
        React.use(promise);
        return <View testID="test" />;
      }

    I imagine a Suspense-enabled library caches a stable reference to a Promise under-the-hood. I did not investigate this further but decided to pass in the Promise as a prop:

      function Suspendable<T>({ promise }: { promise: Promise<T> }) {
        React.use(promise);
        return <View testID="test" />;
      }
    
    // ...
    
      render(
        <React.Suspense fallback={<Fallback />}>
          <Suspendable promise={wait(1000)} />
        </React.Suspense>,
      );

    This enabled experimentation with act and React Test Renderer's create method, which React Native Testing Library calls under-the-hood:

    export function renderWithAct(
    component: React.ReactElement,
    options?: Partial<TestRendererOptions>,
    ): ReactTestRenderer {
    let renderer: ReactTestRenderer;
    // This will be called synchronously.
    void act(() => {
    // @ts-expect-error `TestRenderer.create` is not typed correctly
    renderer = TestRenderer.create(component, options);
    });
    // @ts-expect-error: `act` is synchronous, so `renderer` is already initialized here
    return renderer;
    }

    This experimentation started because I first experimented with converting synchronous act calls to asynchronous act calls but did not get anywhere successfully – the component still never seemed to render in its un-suspended state. As a result I decided to switch track and see what would get a successful test pass with minimal React Native Testing Library internals. The test case below passes and may help with determining the final solution:

    test('react test renderer supports components which can suspend', async () => {
      function wait(delay: number) {
        return new Promise<void>((resolve) =>
          setTimeout(() => {
            resolve();
          }, delay),
        );
      }
    
      function Suspendable<T>({ promise }: { promise: Promise<T> }) {
        React.use(promise);
        return <View testID="test" />;
      }
    
      function Fallback() {
        return <View testID="fallback" />;
      }
    
      let renderer: ReactTestRenderer;
    
      await React.act(async () => {
        renderer = TestRenderer.create(
          <React.Suspense fallback={<Fallback />}>
            <Suspendable promise={wait(1000)} />
          </React.Suspense>,
        );
      });
    
      let view = within(renderer!.root);
    
      expect(view.getByTestId('fallback')).toBeDefined();
    
      await waitFor(() => {
        view = within(renderer!.root);
        view.getByTestId('test');
      });
    
      expect(view.getByTestId('test')).toBeDefined();
    });
  10. winghouchan commented on Jul 12, 2025

    @winghouchan
    Author

    An additional lesson: Suspense at the root causes a problem where the root gets un-mounted when the Promise resolves and the un-suspended component gets rendered. This is why the test case in the previous message needed the queries to be 're-bound' to the root:

      await waitFor(() => {
        view = within(renderer!.root); // <-- queries 're-bound' to root
        view.getByTestId('test');
      });

    Below is the test case updated to have a View at the root. This removes the need for the above waitFor:

    test('react test renderer supports components which can suspend', async () => {
      function wait(delay: number) {
        return new Promise<void>((resolve) =>
          setTimeout(() => {
            resolve();
          }, delay),
        );
      }
    
      function Suspendable<T>({ promise }: { promise: Promise<T> }) {
        React.use(promise);
        return <View testID="test" />;
      }
    
      function Fallback() {
        return <View testID="fallback" />;
      }
    
      let renderer: ReactTestRenderer;
    
      await React.act(async () => {
        renderer = TestRenderer.create(
          <View>
            <React.Suspense fallback={<Fallback />}>
              <Suspendable promise={wait(1000)} />
            </React.Suspense>
          </View>,
        );
      });
    
      let view = within(renderer!.root);
    
      expect(view.getByTestId('fallback')).toBeDefined();
      expect(await view.findByTestId('test')).toBeDefined();
    });

    In the final solution, however, I imagine the possibility of having a Suspense component at the root should be handled.

  11. winghouchan commented on Jul 12, 2025

    @winghouchan
    Author
  12. winghouchan commented on Jul 12, 2025

    @winghouchan
    Author
  13. added a commit that references this issue on Jul 13, 2025
    05e10f7
  14. winghouchan commented on Jul 13, 2025

    @winghouchan
    Author

    The commit above is a demo of making render asynchronous. The test case for rendering components also has a similar problem where it's flaky when run in isolation and always fails when run with other tests. However the failure is slightly different:

    Unable to find an element with testID: test
    
        <View>
          <View
            testID="test"
          />
        </View>
    

    An error of being unable to find an element with a given test ID then printing a tree with the element with the given test ID is baffling 🤔

  15. mdjastrzebski commented on Jul 14, 2025

    @mdjastrzebski
    Member

    @winghouchan Thanks for thorough investigation. Regarding your test using RTR + await act the reason for flakyness seems to be that you hit the findBy* timeout limit which is 1000 ms, if you either reduce wait time or increase the timeout duration the thigs seem to work better:

    // Configure globally
    configure({
      asyncUtilTimeout: 5000,
    });
    
    // Configure per query
    expect(await view.findByTestId('test', undefined, { timeout: 5000 })).toBeDefined();
    

    Pls note, I'm still all other mentioned issues.

  16. 15 remaining items

  17. mikelros commented on Aug 4, 2025

    @mikelros

    @mdjastrzebski could you please provide an update on the ETA for this? Thank you for the effort.

  18. mdjastrzebski commented on Aug 4, 2025

    @mdjastrzebski
    Member

    I still have to solve fake timers, as the PR only supports real timers. Would having just real timers support be ok with you?

  19. mdjastrzebski commented on Aug 7, 2025

    @mdjastrzebski
    Member

    @winghouchan, @mikelros I've just released RNTL v13.3 (beta) with React Suspense support. 🎉 It adds renderAsync, fireEventAsync, screen.rerenderAsync, etc variants that should work correctly with React Suspense.

    Could you test it on your React Suspense code and report back if it works correctly?

  20. devtoil commented on Aug 7, 2025

    @devtoil

    Works for me! Thanks!

  21. mdjastrzebski commented on Aug 12, 2025

    @mdjastrzebski
    Member

    @winghouchan @mikelros any updates from you? I would like to make this API stable soon, and your feedback whether it works for you would be very helpful in go/no-go decision.

  22. winghouchan commented on Aug 12, 2025

    @winghouchan
    Author

    @mdjastrzebski: thanks for your efforts! Sorry, I have seen your message, just haven't got round to testing it yet! I expect to this week.

  23. winghouchan commented on Aug 15, 2025

    @winghouchan
    Author

    @mdjastrzebski: all my tests pass with the 13.3 beta ✅ Happy to call this resolved 😄 Thank you!

  24. mikelros commented on Aug 21, 2025

    @mikelros

    Everything working fine @mdjastrzebski 👌🏼 thanks for the effort!

  25. locked and limited conversation to collaborators on Aug 21, 2025
  26. unlocked this conversation on Aug 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions