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

Support for React Server Components

Open remy90 opened this issue 1 year ago • 116 comments

Describe the feature you'd like:

As a user of react 18 with NextJS (with app directory), I would like to render async server components

example: // Page.tsx

const Page = async ({ params, searchParams }: PageProps) => {
    const asyncData = await fetchSomeDataAsynchronously()
    return (<foo {...asyncData} />
}

... // Page.test.tsx

it('should render', () => {
    render(<Page />)
    expect(screen....).ToBe(....)
})

Extracting server page logic would be an alternative, but I think that would also significantly reduce the purpose of RTL if that were to become an encouraged architectural default.

Current progress, workarounds, and demo

remy90 avatar May 09 '23 14:05 remy90

You can use like this:

it('should render', async() => {
    render(<Page />);
    await waitFor(()=> {
      expect(screen....).ToBe(....)
    });
});

prakashtiwaari avatar May 11 '23 15:05 prakashtiwaari

@prakashtiwaari Not quite.

It's render that's giving the issue. To provide the full error message:

'Page' cannot be used as a JSX component.
  Its return type 'Promise<Element>' is not a valid JSX element.
    
Type 'Promise<Element>' is missing the following properties from type 'ReactElement<any, any>': type, props, keyts(2786)

remy90 avatar May 11 '23 16:05 remy90

A friend came up with the following:

const props = {
  params: { surveyType: '123' },
  searchParams: { journeyId: '456' },
}

const Result = await Page(props)
render(Result)

I'll wait for a moderator to close this but it would be nice to have async components supported inside render

remy90 avatar May 11 '23 17:05 remy90

Once TypeScript 5.1 is out (scheduled for 30th of May) we'll land https://github.com/DefinitelyTyped/DefinitelyTyped/pull/65135 which fixes this issue.

eps1lon avatar May 11 '23 18:05 eps1lon

A friend came up with the following:

const props = {
  params: { surveyType: '123' },
  searchParams: { journeyId: '456' },
}

const Result = await Page(props)
render(Result)

I'll wait for a moderator to close this but it would be nice to have async components supported inside render

I like this idea personally. It should be easy enough to introspect if a component is async by determining if it returns a Promise. Then we can await the Promise internally and either return a Promise in render or poll to return synchronously.

nickmccurdy avatar May 13 '23 12:05 nickmccurdy

For those that are trying to mock API responses, this seems to be working for now:

test("should do stuff", async () => {
  global.fetch = jest.fn().mockResolvedValue({
    json: jest.fn().mockResolvedValue({ login: "Gio" }),
  });
  render(await Page());
  expect(screen.getByText("Hi Gio!")).toBeInTheDocument();
});

If you don't define global.fetch, you get ReferenceError: fetch is not defined.

I would love to be able to use MSW, but that's still not working with Next's model. See: https://twitter.com/ApiMocking/status/1656249306628915208?s=20

On a side note, even if we make this work, there's the issue of Next layout composition. If I render a Page component in my test, I most likely want to render also its layout(s). I understand this is not necessarily a concern that RTL should have, but we should keep it in mind. Next could provide an API for testing that builds the whole rendering tree.

Gpx avatar May 14 '23 19:05 Gpx

We could document using layouts similarly to how we already document using providers: by importing them and passing them as the wrapper option of render.

import {render} from '@testing-library/react'
import Layout from './layout'
import Page from './page'

render(<Page />, {wrapper: Layout})

@Gpx has a good point that rendering nested layouts would be more challenging. We could try to expose an abstraction, but the paths to import from would be dynamic and depend on Next patterns. If this isn't practical, we can open an issue with Next upstream and ask for an API to consume internally.

Also if we change render to be compatible with returning promises, we should probably ship a breaking change so end users are aware they may need to change their tests (especially if they're using TypeScript).

nickmccurdy avatar May 15 '23 08:05 nickmccurdy

I agree we should ask Next to provide an API. I'm thinking something like this:

const RSC = routeComponent('/some/path') // This will return an RSC with all nested layouts passing the correct props
render(RSC)

// With additional props
const RSC = routeComponent('/some/path', { foo: 1 })
render(RSC)

WDYT?


As for the breaking change, won't this be a feature? I don't think anyone was relying on render breaking for async components.

Gpx avatar May 15 '23 10:05 Gpx

I'm doing more research into RSCs using Next, and I noticed some mistaken assumptions I had:

  1. Determining if a component uses suspense is not possible due to the halting problem as it would be valid for the component to sometimes not return a Promise, and we can't distinguish between a component not returning a Promise currently vs. ever.
  2. The new rendering async APIs don't return Promises, they return streams. So, we don't necessarily need to make our render function async, though we may need to poll to unwrap streams synchronously.

Overall, I think supporting React server components involves figuring out the following concerns:

  1. https://github.com/DefinitelyTyped/DefinitelyTyped/pull/65135
  2. Figuring out how we can internally render async components (may involve using a different render API, and would require using experimental/canary React right now)
  3. Supporting bundling and routing strategies from different frameworks (we could use the adapter pattern to compose APIs from RSC frameworks such as https://github.com/vercel/next.js/discussions/50479)

nickmccurdy avatar May 15 '23 21:05 nickmccurdy

I agree we should ask Next to provide an API. I'm thinking something like this:

const RSC = routeComponent('/some/path') // This will return an RSC with all nested layouts passing the correct props
render(RSC)

// With additional props
const RSC = routeComponent('/some/path', { foo: 1 })
render(RSC)

WDYT?

@Gpx Shouldn't that be render(<RSC />)? Also are you suggesting the URL path or filesystem path here?

nickmccurdy avatar May 15 '23 22:05 nickmccurdy

The idea to render the server component as async client components seems to be the best idea I've seen so far.

I don't think it's suitable for RTL to bake Next.js' (or any other framework's) semantics into it's API. Async client components solve this by being agnostic of any framework.

I think this would retain the current RTL API with maybe the addition of a suspense wrapper - rendering nothing initially could be confusing so enforcing a fallback makes sense.

render(<Page />) // throws an error because there's no boundary (I think this would be default react behaviour)
render(<Suspense><Page /></Suspense>) // This would be ok

tom-sherman avatar May 15 '23 22:05 tom-sherman

For anyone that wants to help, I'm trying to figure out why I can't hydrate into a server rendered document, it's like it's destroying the whole document. https://gist.github.com/nickmccurdy/a5797bec9bb7e1156f814846c9bcb04b https://github.com/nickmccurdy/rsc-testing

nickmccurdy avatar May 16 '23 03:05 nickmccurdy

Why are you trying to server render? RSC doesn't rely on SSR, that's an optional optimisation step. See https://github.com/dai-shi/wakuwork for an example of this

You need to call createFromFetch/readable from the react-server-dom-webpack/client and pass that element to react Dom to render. This ofc relies on having access to a RSC stream which you can create from the same package.

tom-sherman avatar May 16 '23 06:05 tom-sherman

I'd rather not add APIs specific to bundlers, especially since Next has already moved to other bundlers and we still have others to support in the future.

nickmccurdy avatar May 16 '23 07:05 nickmccurdy

That isn't really a bundler specific API, it includes the shared pieces shared across all bundlers. Source.

tom-sherman avatar May 16 '23 07:05 tom-sherman

I do think it's easier to just mount the server component as a client component with createRoot though - this works in react-dom canary today.

See commit on your test repo here: https://github.com/tom-sherman/rsc-testing/commit/9e4aa67dafae735440fb75a481c2ffc2a87671ec

I'm not sure how RTL can support rendering of the root layout in Next.js, but as I said before I don't think it should - at least not part of it's core API. The root layout returning <html> is a Next.js-specific idiom, not all server component frameworks choose to work this way.

tom-sherman avatar May 16 '23 07:05 tom-sherman

Thanks for the branch, that's interesting. I'd still like to understand why this works and onAllReady doesn't though, as that's supposed to be used for SSG where having suspense fallbacks everywhere wouldn't be accessible. Also, while I understand why your version of the test needs to await for a suspended render, I'd rather not make end users worry about understanding this if there's a way to ensure certain components load automatically. Additionally, both branches still have act warnings, though I have no idea how to fix this as I've already tried using it in various ways.

nickmccurdy avatar May 16 '23 09:05 nickmccurdy

Also, while I understand why your version of the test needs to await for a suspended render, I'd rather not make end users worry about understanding this if there's a way to ensure certain components load automatically

You're going to need an await somewhere - you can't syncronously block because it's an async component. I suppose you could have a builtin suspense boundary and have the user await render(<Page />) but as soon as the user adds a suspense boundary somewhere they're gonna need to use waitFor anyway.

tom-sherman avatar May 16 '23 09:05 tom-sherman

That's closer to what I was thinking originally. Though, I think I'm confused about how this is working, but I'll reply here again when I resolve it.

nickmccurdy avatar May 17 '23 09:05 nickmccurdy

I agree we should ask Next to provide an API. I'm thinking something like this:

const RSC = routeComponent('/some/path') // This will return an RSC with all nested layouts passing the correct props
render(RSC)

// With additional props
const RSC = routeComponent('/some/path', { foo: 1 })
render(RSC)

WDYT?

@Gpx Shouldn't that be render(<RSC />)? Also are you suggesting the URL path or filesystem path here?

No, I think it should be render(RSC) where RSC is something like <Component {...nextProps} >. Alternatively routeComponent could return a component and its props:

const { Component, props } = routeComponent('/some/path')
render(<Component {...props} />)

We need not only the components tree but also the props that Next is passing.


The URL should be passed to the method, not the filesystem path. If we want to simulate a real user interacting with the app they'll use URLs.


To be clear, I'm not saying we should implement this in RTL, but rather that we should ask the Next team to provide it since it will be helpful for anyone doing tests.

Gpx avatar May 17 '23 11:05 Gpx

I'm not sure we need it to return params, since you can just choose what params to render in your test by changing the URL.

nickmccurdy avatar May 17 '23 14:05 nickmccurdy

I'm not sure we need it to return params, since you can just choose what params to render in your test by changing the URL.

Say you want to test the URL /foo/bar/baz. What are the params? Well, it depends on your file system:

Route params
app/foo/[slug]/baz { slug: 'bar' }
app/foo/bar/[slug] { slug: 'baz' }
app/foo/[[...slug]] { slug: ['bar', 'baz'] }

I can create the params object in my tests and pass it to the component, but if later I modify the filesystem, I might break my code, and my tests will still work.

If we want to keep the render(<Component />) format rather than render(Component) Component could just render the tree passing the correct params.

Gpx avatar May 18 '23 07:05 Gpx

Do we agree that RTL (at least in the core API) shouldn't support this kind of routing stuff? If so probably best to split that conversation out into a different issue?

tom-sherman avatar May 18 '23 08:05 tom-sherman

I agree, I'll open an issue in Next's repo

Gpx avatar May 18 '23 16:05 Gpx

If we want to keep the render(<Component />) format rather than render(Component) Component could just render the tree passing the correct params.

@Gpx Yes, I think that's simpler, and I'd rather avoid exposing the implementation detail of param values.

Do we agree that RTL (at least in the core API) shouldn't support this kind of routing stuff? If so probably best to split that conversation out into a different issue?

@tom-sherman I'm not suggesting we add a Next specific app router API directly into Testing Library. However, I'd like us to have either docs or some sort of adapter/facade/etc. design pattern that composes a Next routing primitive.

nickmccurdy avatar May 18 '23 17:05 nickmccurdy

Guys any example how i can test pages inside app/[locale] folder with providers (next-intl)? Because i am getting invariant expected app router to be mounted

This is my test file

import { render, screen } from '@testing-library/react';
import Home from '@/app/[locale]/page';
import Layout from '@/app/layout';
import Header from '@/components/header';
import { Providers } from '@/app/providers';
import Footer from '@/components/footer';
import SessionModal from '@/components/Modals/SessionModal';
import { NextIntlClientProvider } from 'next-intl';
import messages from '../messages/en.json';

describe('Home', () => {
  it('renders a heading', () => {
    render(
      <NextIntlClientProvider locale={'en'} messages={messages}>
        <Header />
        <Providers>
          <Home />
        </Providers>
        <Footer />
        <SessionModal />
      </NextIntlClientProvider>,
      { wrapper: Layout }
    );

    expect(screen.getByRole('heading')).toHaveTextContent('Let’s Get Started!');
  });
});

DonikaV avatar May 25 '23 13:05 DonikaV

@DonikaV Could you share a full repository or Gist that reproduces the error?

nickmccurdy avatar May 26 '23 07:05 nickmccurdy

@nickmccurdy hey, no i can't but i resolved finally it, errors was because of useRouter() This helps me

jest.mock('next/navigation', () => ({
  ...require('next-router-mock'),
  useSearchParams: () => jest.fn(),
}));

DonikaV avatar May 26 '23 10:05 DonikaV

FYI request for a testing method in Next https://github.com/vercel/next.js/discussions/50479

Gpx avatar May 29 '23 08:05 Gpx

Progress

I had some helpful suggestions from the React team on how to start up an RSC server with full support for React RSC features (excluding Next specific features for now). I'm developing a renderServer function that simulates a React server with our existing React client.

Full integration with Next's app router depends on https://github.com/vercel/next.js/discussions/50479.

Workarounds

You can test most async components with React 18.3 (canary) or 19 (unreleased):

import { render, screen } from "@testing-library/react";
import { Suspense } from "react";
import Page from "./page";

test("Page", async () => {
	render(
		<Suspense>
			<Page />
		</Suspense>,
	);

	await screen.findBy...(...); // first assertion must await for suspense
	// additional assertions may follow
});

You may want to use a custom render function to simplify test setup if your suite heavily relies on async components.

If you need other RSC (i.e. server actions) or app router (i.e. layouts) features you can use hard coding, mocks, or an e2e test framework until we figure out these issues.

server-only errors

Some React Server Components import the server-only module to prevent accidental usage in Client Components, resulting in this error:

This module cannot be imported from a Client Component module. It should only be used from a Server Component.

React Testing Library doesn't have a server yet, so it needs to render Server Components in its client for now.

If you're using Jest or Vitest, you can disable the module's error with an empty mock script named __mocks__/server-only.

With Vitest, you'll also need to manually register it:

vi.mock("server-only");

Alternatively you can mock the module in a setup or test file, for example:

jest.mock("server-only");
vi.mock("server-only", () => ({}));

TypeScript errors

Use typescript@^5.1.2 and @types/react@^18.2.8 to fix this error when rendering async components:

'...' cannot be used as a JSX component. Its return type 'Promise' is not a valid JSX element. Type 'Promise' is missing the following properties from type 'ReactElement<any, any>': type, props, key

React warnings

Newer versions of React 18.3 (canary) added a warning when rendering server components in clients:

Warning: async/await is not yet supported in Client Components, only Server Components

However, tests following my suggestions should still work for now. I believe the warning mainly exists to prevent accidental usage of async components without a meta framework and inform users about potential instability. Remember to pin your React version in a shared package.json or lockfile though, as changes in canaries could be breaking.

Demo

nickmccurdy avatar May 31 '23 09:05 nickmccurdy