react-testing-library icon indicating copy to clipboard operation
react-testing-library copied to clipboard

@testing-library/react behaves differently when dealing with Suspense in React 18 and React 19

Open 0xc14m1z opened this issue 10 months ago β€’ 27 comments

  • @testing-library/react version: 16.1.0
  • Testing Framework and version: jest 29.7.0
  • DOM Environment: jsdom 29.7.0

Relevant code or config:

render(
  <Suspense fallback="fallback">
    <TestComponent />
  </Suspense>
);

expect(screen.getByText("fallback")).toBeInTheDocument();
expect(await screen.findByText("resolved")).toBeInTheDocument();

What you did:

We're upgrading our codebase from React 18.3.1 to React 19.0.0.

What happened:

Over 300 tests started to fail because of suspended components kept rendering their fallbacks and never their children.

Reproduction:

In this repo we created a minimal reproduction for the issue we're facing.

It contains a folder react-18 and a folder react-19 that contains the same dependencies, configuration and code, except for the React version.

In both code bases there's the same test. It passes on the React 18 project, but fails in 2 different ways in the React 19 one.

Run npm test in each folder to see the output.

Problem description:

By default, a test that uses Suspense gets stuck rendering the fallback on React 19.

RTL emits a warning to wrap the render method in an awaited act. Following the suggestion leads the component to un-suspend, but make it impossible to assert against its fallback.

Suggested solution:

We don't have a solution for this problem, but we think RTL should behave the same with React 18 and React 19.

0xc14m1z avatar Jan 09 '25 09:01 0xc14m1z

Thanks @0xc14m1z. @eps1lon I think we should move forwards with the open PR we have for await act.. Wdyt?

MatanBobi avatar Jan 09 '25 09:01 MatanBobi

I am also experiencing a similar issue. Same test code worked in React 18, but broken after upgrading to React 19.

Prerequisites

  • Using fake timers.
  • The component under test fetches data and displays a suspense fallback during the fetch.

Steps to Reproduce

  1. Create a test case that renders a specific component and uses a findBy query to locate specific text displayed after the data fetch completes.
  2. Copy and paste the the case multiple times within the same file.
  3. Run the test file.
  4. Only the first test case executed passes, while the others fail. The failing test cases remain in a suspended state. Occasionally, test cases other than the first one also pass, but this is intermittent.

Workaround

(This seems to not apply to all cases, but I am noting it just for reference) I replaced the findBy query with the following workaround:

Before

await screen.findByRole('heading', { level: 1 });

After

const temp = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
const result = await vi.waitFor(() => {
  screen.getByRole('heading', { level: 1 });
});
globalThis.IS_REACT_ACT_ENVIRONMENT = temp;

centraldogma99 avatar Jan 10 '25 08:01 centraldogma99

I think we should move forwards with the open PR we have for await act.. Wdyt?

That's the plan for January. Just need to find time.

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.

eps1lon avatar Jan 10 '25 09:01 eps1lon

We have about 3'000 frontend tests - while "only" 10% are currently failing, I'm afraid we'll have to wait and see what happens next. I sill find it weird though that a "render" call on a component tree that starts being suspended seems to starve to death with the children never being rendered.

flq avatar Jan 10 '25 10:01 flq

Thanks @eps1lon for taking care of this.

We're going to postpone the upgrade because we have around 300 tests failing because of this issue. Some of them assert against the fallback, so they'll fail anyway when wrapping the suspended updates with the async act.

The workaround would have to be reverted once the issue is fixed.

0xc14m1z avatar Jan 10 '25 10:01 0xc14m1z

@0xc14m1z What were the common points of the failing tests?

centraldogma99 avatar Jan 12 '25 07:01 centraldogma99

We're seeing a lot of failures where we have hooks that suspend on the initial render e.g. Apollo's useSuspenseQuery.

Will we be required to wrap render calls in act to handle this?

tpict avatar Jan 16 '25 18:01 tpict

@centraldogma99 weβ€˜re seeing tests that hang in staying suspended - the asynchronous call is done, but the Suspense boundary will never render its children. Wrapping render in act will cause the boundary to render its children, however, the suspense placeholder is not observable by the test anymore.

flq avatar Jan 16 '25 22:01 flq

Is there any update here? Is there something that can be done to help resolving this issue?

0xc14m1z avatar Feb 14 '25 08:02 0xc14m1z

I have 1000s of unit tests, all using a custom wrapper around render() to automatically add <Suspense> as we use Jotai store and most components just access the required atoms directly using useAtomValue. This is basically the setup we're using:

function wrap({ children }: { children: React.ReactNode }) {
  const store = createStore();

  return (
    <JotaiProvider store={store}>
      <Suspense fallback={<div data-testid="suspense" />}>
        {children}
      </Suspense>
    </JotaiProvider>
  );
}

export function renderWrapped(ui: React.ReactNode, options?: RenderOptions) {
  return render(ui, { ...options, wrapper: wrap });
}

export async function waitForSuspense() {
  const suspense = screen.queryByTestId('suspense');

  if (suspense) {
    await waitForElementToBeRemoved(() => screen.queryByTestId('suspense'));
  }
}

We're NOT having a good time πŸ˜…

wojtekmaj avatar Feb 20 '25 13:02 wojtekmaj

Is there a way for us to replicate the proposed new behavior while the relevant PRs are in flight? I'm in a similar position to wojtekmaj and it's the last item blocking our upgrade to React 19.

tpict avatar Feb 28 '25 16:02 tpict

I think we should move forwards with the open PR we have for await act.. Wdyt?

That's the plan for January. Just need to find time.

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.

Does this also apply to convenience methods such as fireEvent if they trigger a re-render? I read somewhere that the React testing library already integrated act with its APIs. So in most cases, we do not need to wrap render and fireEvent in act.

However since upgrading to React 19 and using Suspense I noticed that when a re-render happens due to fireEvent I need to wrap it with act() otherwise I also get a warning informing act is needed.

christopher1986 avatar Mar 17 '25 19:03 christopher1986

any update?

GeDiez avatar Mar 27 '25 16:03 GeDiez

@eps1lon do you guys need any help with pushing forward with the await act changes?

Codexphere92 avatar Mar 29 '25 19:03 Codexphere92

I have successfully tested the following component

import { useSuspenseQuery } from '@apollo/client';

const EditPopupDivider = () => {
  const { data } = useSuspenseQuery(QUERY);

  return <>custom text</>;
};

const Component = () => {
  return (
    <Suspense fallback={<Loader />}>
      <Divider {...props} />
    </Suspense>
  );
};

with the following test

import { act, render, screen } from '@testing-library/react';

it('should', async () => {
  await act(() => {
    render(
      <ApolloProvider client={client}>
        <Component />
      </ApolloProvider>,
    );
  });

  await screen.findByText('custom text');
});

Myllaume avatar Apr 02 '25 09:04 Myllaume

@Myllaume I think the problem relates to updates when state or property changes through user interactions and this triggers a rerender. More specifically when a button is clicked you would normally use fireEvent() which already wraps act() from React but unfortunately we still need to explicitly wrap it with act() which previously was not the case.

Or at least that's the problem I am encountering with React 19 and using Suspense with the (new) use hook.

christopher1986 avatar Apr 03 '25 17:04 christopher1986

^ yeah that is what our team is running into too. there are a bunch of tests failing unless we wrap the user events with act() which then throws a warning saying act shouldn't be uused.

Codexphere92 avatar Apr 03 '25 18:04 Codexphere92

Is there any update/solution for this yet? I'm having issues testing the components that use <Suspense> if I don't wrap the rendering with act(). react 19.1, @testing-library/react 16.3, and @testing-library/dom 10.4

matiascortinez avatar Apr 24 '25 16:04 matiascortinez

We are struggling as well with this issue.

So far we started using act() in a bunch of places as an ugly workaround. We're even considering patching React.lazy in the testing environment so we synchronously load the components to avoid the issue (I know, I don't like it either but we're running our of ideas).

CarlosCortizasCT avatar Apr 24 '25 16:04 CarlosCortizasCT

Just started down the upgrade path as well and am running into this issue. Our custom render function uses a Supsense boundary, and a lot of our components are lazy loaded, so all of those tests break when using the render method unless I wrap the invocation in an await act(() => render(<Component />))

miketheodorou avatar May 08 '25 16:05 miketheodorou

Any news on this side? Wrapping the render around an await act() is the new standard for components with Suspense?

(Worked on our side as well)

gabgagnon avatar May 21 '25 22:05 gabgagnon

Nope, seems like the maintainers are busy working on the nextjs repo only.

Codexphere92 avatar May 22 '25 00:05 Codexphere92

I am beginning to wonder - do they not write tests anymore? Or how do they test? We came once from enzyme and it was quite the effort, but seemed worth it. Now it seems that we are again stuck in a cul-de-sac. Is the only answer to frontend tests maintainable over years full-on integration testing in the browser?

flq avatar May 25 '25 10:05 flq

@eps1lon any (status) updates on this issue or help needed to get this issue fixed?

christopher1986 avatar May 25 '25 10:05 christopher1986

It's certainly a workaround, but in the meantime, we created a helper function for these sorts of tests.

export async function renderLazyComponent<T>(callback: () => T): Promise<T> {
  return await act(callback);
}

Then in our individual tests, we use it like so:

const renderComponent = (props?: ComponentProps) => {
  return renderLazyComponent(() => render(<Component {...props} />));
};

describe('<Component />', () => {
  test('renders the component', async () => {
    await renderComponent();
   //... assertion
  });
})

miketheodorou avatar May 27 '25 18:05 miketheodorou

For me none of the provided workarounds did the trick. What I ended up doing is importing the lazy-loaded component in the beforeAll(). React seems to cache the import, causing the component to be already loaded during the tests.

beforeAll(async () => {
  await import('./component-that-is-lazy-loaded');
})

I'm using Vitest. I fear Jest doesn't support asynchronous before* and after* functions yet (the documentation doesn't mention it at least).

vinibanaco avatar Jun 03 '25 16:06 vinibanaco

I use this code for testing hook:

import { act, renderHook } from "@testing-library/react";

it("should", async () => {
  let hookResult;

  await act(() => {
    const { result } = renderHook(() => useSomething(), {
      wrapper: ({ children }) => (
        <Provider client={client}>{children}</Provider>
      ),
    });

    hookResult = result;
  });

  // if it really is madness
  await waitFor(() => {
    expect(hookResult.current).not.toBeNull();
  });
});

Myllaume avatar Jun 04 '25 06:06 Myllaume

Is there any updates here? Wrapping render with await act(async () => { ... }) in some cases (???) doen't work. It hangs and throws OutOfMemory over the time.

aleonov-alt avatar Aug 08 '25 07:08 aleonov-alt

Since my previous comment, we've moved to async render helper functions, and I would suggest you to do the same, as the change seems inevitable (vitest-browser-react also moves to async render function in v2).

The setup that works for us:

import { act, render } from '@testing-library/react';
import { Suspense, useEffect } from 'react';

import type { RenderOptions, RenderResult } from '@testing-library/react';

function makeWrapper(resolve: () => void) {
  function Resolver(): undefined {
    useEffect(resolve);
  }

  return function Wrapper({ children }: { children: React.ReactNode }): React.ReactElement {
    return (
      <Suspense fallback={null}>
        {children}
        <Resolver />
      </Suspense>
    );
  };
}

export async function renderWrapped(
  ui: React.ReactNode,
  options?: RenderOptions,
): Promise<RenderResult> {
  const { promise, resolve } = Promise.withResolvers<void>();

  const result = await act(async () => render(ui, { ...options, wrapper: makeWrapper(resolve) }));

  await promise;

  return result;
}

wojtekmaj avatar Aug 08 '25 08:08 wojtekmaj

I just reported an issue to react. maybe it's related

jzhan-canva avatar Aug 21 '25 13:08 jzhan-canva