storybookjs/storybook

[Feature Request]: Support React Server Components (RSC)

IGassmann opened this issue ยท 43 comments

Is your feature request related to a problem? Please describe

No response

Describe the solution you'd like

I'd like to render react server components in Storybook.

Describe alternatives you've considered

No response

Are you able to assist to bring the feature to reality?

no

Additional context

I'm creating this issue so we can subscribe to updates on any new plan/progress regarding the support of React Server Components in Storybook.

We'll be investigating this at some point after 7.0 is released. Thanks for filing the issue! cc @valentinpalkovic

Following! I've used https://www.npmjs.com/package/@storybook/server in the past with PHP projects, it'd be cool to see a similar concept of mixing some server-side work with the existing client-side approach.

It's quite hard to find information on RSC at the moment other than looking at the React and Next 13 app router source code, but this stream (recording and repo via the link) seems like it might be useful for library/framework maintainers: https://twitter.com/BHolmesDev/status/1641609335653449730 (feat. Ben Holmes from Astro and Abramov)

Next.js now has a stable release of their usage of RSC via the Next.js app router https://nextjs.org/blog/next-13-4

We did a proof of concept last year with https://www.npmjs.com/package/@storybook/server. We'll need to dedicate some time to dusting that off and figuring a path forward now that 7.0 is released!

pm0u commented

@shilman would that POC work with v7 and is the source available somewhere? The lack of server component support is the main blocker for us to transition to the app directory. I tried to poke around the source for @storybook/server but it was not clear to me how to use it.

@pm0u It's not public -- it's very hacky and I didn't want to confuse anybody or send mixed signals. I'd like to do a proper POC soon and share it out to the community for feedback.

However, you should know that this is all really early work. @valentinpalkovic is our local NextJS expert and hopefully he can give you some advice on best practices for the app directory.

Hitting the same roadblocks here... Server actions are also an issue. They're still experimental and will probably change some, but it might be a good idea to keep them in mind

+1
This is creating a great lack of coverage on a new project I'm working.
Hope in the near future we can use Storybook on RSC.

I found a quick workaround: export a not async component that receives the fetched data. And make your async component return it passing the fetched data in properties.

At the Stories file, you mock the props.

// Async Component
export const Component = ({ agent }: { agent: AgentModel }) => {
  if (!agent) return <></>;

  return (
    <>
      <Header {...agent} />
      <Chat agent={agent.agent} />
    </>
  );
};

const Page = async ({ params }: Model) => {
  const agent = await getAgent(params?.agent);

  return <Component agent={agent} />;
};

export default Page;
// Stories
import { Component } from "./page";
import { PageComponentMock } from "./model";

const meta: Meta<typeof Component> = {
  component: Component,
};

export default meta;
type Story = StoryObj<typeof Component>;

export const Default: Story = {
  args: PageComponentMock 
};

I have two projects affected with this. I found this workaround based on we don't want to adapt or apply any workaround to our main UIKit elements:

import type { Meta, StoryFn } from '@storybook/react'

import { Header } from './Header'

const meta: Meta<typeof Header> = {
  /* ๐Ÿ‘‡ The title prop is optional.
   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: 'Design System/Templates/Server/Header',
  // component: Header,
  loaders: [
    async () => {
      const HeaderComponent = await Header({ fixed: true, locale: 'es' })
      console.log({ Header, HeaderComponent })
      return { HeaderComponent }
    },
  ],
  parameters: {
    controls: { sort: 'requiredFirst' },
    nextjs: {
      appDirectory: true,
    },
  },
}

export default meta
// type Story = StoryObj<typeof Header>

/*
 *๐Ÿ‘‡ Render functions are a framework specific feature to allow you control on how the component renders.
 * See https://storybook.js.org/docs/react/api/csf
 * to learn how to use render functions.
 */
export const Primary: StoryFn = (args, { loaded: { HeaderComponent } }) => {
  console.log({ args })
  return HeaderComponent
}

// export const LoggedIn: Story = {
//   render: () => <Header fixed isLoggedIn locale="es" />,
// }

The problem with this workaround is basically, as you can see, it's not possible update the Header component (our async server componente) from the stories.

It's there any estimation about when or who this is goint to be achieve?

Really thanks for the Storybook team for this work

@icastillejogomez we don't have an ETA, since we don't know the right approach yet. i have a semi-working prototype that renders using NextJS server, but which doesn't really fit with Storybook's typical approach to rendering components on the client. There is also some interesting work going on in testing-library that could be relevant and we're following as it progresses.

In the meantime, if anybody wants to help this along, please upvote vercel/next.js#50479

#21540 (comment)

Another workaround:

When crafting a story for a component that involves a server component (e.g., app/page), utilizing dependency injection to integrate components might aid in presenting server component narratives, albeit with some increased intricacy.

How do you feel about this approach?

// app/page.tsx

interface MyClientComponentProps {
  title: string;
}
function MyClientComponent({ title }: MyClientComponentProps) {
  return <>{title}</>;
}

// Server component
async function MyServerComponent() {
  const res = await fetch("https://jsonplaceholder.typicode.com/todos/1");
  const data = (await res.json()) as { title: string };

  return <MyClientComponent title={data.title} />;
}

type Props = {
  myComponent?: React.ReactNode;
} & Partial<React.ComponentProps<typeof MyClientComponent>>;
export function Page({
  title,
  // Using dependency injection for Storybook
  myComponent = title ? <MyClientComponent title={title} /> : null,
}: Props) {
  return <>{myComponent}</>;
}

export default function PageForServerSide() {
  return <Page component={<MyServerComponent />} />;
}
// app/page.stories.tsx
// Story of the component containing the server component

import { Page } from "./page";

export default {
  title: "Page",
  component: Page,
};

export const Default = {
  args: {
    title: "Hello Storybook!",
  },
};

Storybook should be able to map from internal builders like @storybook/builer-webpack5 and @storybook/builer-vite to React Server implementations like react-server-dom-webpack and react-server-dom-vite (respectively). Alternatively, this could be composed at the builder or framework API level.

+1 This is creating a great lack of coverage on a new project I'm working. Hope in the near future we can use Storybook on RSC.

Same ... but I am just finding out about the lack of support and I am already committed to RSC ๐Ÿ˜ข

+1 for this RSC support feature

kacyee commented

+1 for RSC support feature, we want next components in storybook :)

+1 please!!! orz

Instead of commenting "+1," please use the thumbs-up (๐Ÿ‘๐Ÿผ) reaction on the issue. This way, we can avoid sending unnecessary notifications to everyone who is subscribed to this issue.

Hello! Any news on whether or not Storybook will support RSC? If so, are there any progress updates? Thanks for all the great work.

We have a working prototype and plan to release experimental support in the coming months. Please keep an eye out here for updates soon! @sethburtonhall

This decorator workaround worked for me

This is so you can render async components. It also (I don't know how) works with nested async components as well

import { Decorator } from '@storybook/react';
import { Suspense } from 'react';

export const WithSuspense: Decorator = (Story) => {
  return (
    <Suspense>
      <Story />
    </Suspense>
  );
};
z0n commented

This decorator workaround worked for me

This is so you can render async components. It also (I don't know how) works with nested async components as well

import { Decorator } from '@storybook/react';
import { Suspense } from 'react';

export const WithSuspense: Decorator = (Story) => {
  return (
    <Suspense>
      <Story />
    </Suspense>
  );
};

Whoa, that's really cool! There was a bit more I had to do as I'm using useRouter from next/navigation in my component but here's my working story:

import type { Meta, StoryObj } from '@storybook/react'
import { Suspense } from 'react'

import { MyServerComponent } from './MyServerComponent'

const meta = {
  title: 'RSC',
  component: MyServerComponent,
  argTypes: {},
  tags: ['autodocs'],
  decorators: [
    Story => (
      <Suspense>
        <Story />
      </Suspense>
    ),
  ],
} satisfies Meta<typeof MyServerComponent>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  args: {
    exampleProp1: '123456789',
    exampleProp2: '987654321',
  },
  parameters: {
    nextjs: {
      appDirectory: true,
    },
  },
}

Edit: You can also set the nextjs parameters globally in your preview.tsx if you're not using the pages directory at all:

const preview: Preview = {
  parameters: {
    nextjs: {
      appDirectory: true,
    },
    ...
  },
  ...
}

export default Preview

d

This decorator workaround worked for me
This is so you can render async components. It also (I don't know how) works with nested async components as well

import { Decorator } from '@storybook/react';
import { Suspense } from 'react';

export const WithSuspense: Decorator = (Story) => {
  return (
    <Suspense>
      <Story />
    </Suspense>
  );
};

Whoa, that's really cool! There was a bit more I had to do as I'm using useRouter from next/navigation in my component but here's my working story:

import type { Meta, StoryObj } from '@storybook/react'
import { Suspense } from 'react'

import { MyServerComponent } from './MyServerComponent'

const meta = {
  title: 'RSC',
  component: MyServerComponent,
  argTypes: {},
  tags: ['autodocs'],
  decorators: [
    Story => (
      <Suspense>
        <Story />
      </Suspense>
    ),
  ],
} satisfies Meta<typeof MyServerComponent>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  args: {
    exampleProp1: '123456789',
    exampleProp2: '987654321',
  },
  parameters: {
    nextjs: {
      appDirectory: true,
    },
  },
}

Edit: You can also set the nextjs parameters globally in your preview.tsx if you're not using the pages directory at all:

const preview: Preview = {
  parameters: {
    nextjs: {
      appDirectory: true,
    },
    ...
  },
  ...
}

export default Preview

Does this method allow for mocking fetch requests made in server components?

z0n commented

Does this method allow for mocking fetch requests made in server components?

I haven't tried it yet, you might be able to use msw though.

Amazing @JamesManningR @z0n !! I just tried this out on a sample project and it's working great. Going to play with MSW today and post a sample repo.

NOTE: For anybody playing with it, this technique only works with the webpack builder & not with the Vite builder (which was the first sample project I tried). If we end up pushing this solution, will try to get to the bottom of that. We've also got a very different solution that I'll also be sharing soon.

@JamesManningR, this is incredible work! Iโ€™m with @shilman in the core SB team, and as a way of saying thanks weโ€™d love to send you some Storybook swag!

I reached out to you on LinkedIn!

Does this method allow for mocking fetch requests made in server components?

I haven't tried it yet, you might be able to use msw though.

MSW worked for me

apiel commented

While using Suspense I am getting headers() expects to have requestAsyncStorage, none available in the story. Any idea how to solve this?

While using Suspense I am getting headers() expects to have requestAsyncStorage, none available in the story. Any idea how to solve this?

Try this: vercel/next.js#50068 (comment)

apiel commented

While using Suspense I am getting headers() expects to have requestAsyncStorage, none available in the story. Any idea how to solve this?

Try this: vercel/next.js#50068 (comment)

We already have those settings, so no, it doesn't work :-/

This decorator workaround worked for me

This is so you can render async components. It also (I don't know how) works with nested async components as well

import { Decorator } from '@storybook/react';
import { Suspense } from 'react';

export const WithSuspense: Decorator = (Story) => {
  return (
    <Suspense>
      <Story />
    </Suspense>
  );
};

Isn't this just rendering as an async Client Component, not a React Server Component?
The async Client Component is mentioned here, but the behavior may change in the future.

Isn't this just rendering as an async Client Component, not a React Server Component? The async Client Component is mentioned here, but the behavior may change in the future.

I don't understand how all of these pieces fit together. But my understanding is that Storybook just serves your stuff and mocks everything in the browser.

So that means that RSCs have to live clientside when running Storybook, right? We need to get all the APIs running. That clearly involves supporting async components, but shouldn't you mock your server functions and tainted data anyway?

However, that last bit is pretty spicy. It would be nice to have built-in Storybook support for module mocking. And module mocking is hard to support across multiple build systems.

z0n commented

While using Suspense I am getting headers() expects to have requestAsyncStorage, none available in the story. Any idea how to solve this?

I just noticed the same issue with a component which includes cookies from next/headers.
Other server components don't have that issue.

While using Suspense I am getting headers() expects to have requestAsyncStorage, none available in the story. Any idea how to solve this?

I just noticed the same issue with a component which includes cookies from next/headers. Other server components don't have that issue.

headers and cookies functions are Next specific so probably wouldn't fit in this issue.
It's probably wise to create another issue to deal with that one with the nextjs tag

While using Suspense I am getting headers() expects to have requestAsyncStorage, none available in the story. Any idea how to solve this?

I just noticed the same issue with a component which includes cookies from next/headers. Other server components don't have that issue.

probably a dumb question, but do you already have parameters: { nextjs: { appDirectory: true } } for that story?

Of course, the nextjs preset probably needs work to handle running in fake-server mode.

For anyone looking on how to solve the next headers/cookies and looking for a quick fix: specifically for webpack
#21540 (comment)
#21540 (comment)

Just want to reply with a workaround, and this one is a very workaround-y workaround.

Using this section of the docs
You can create a next headers mock (and by extension cookies)

I've marked any new files with * their location doesn't matter

// .storybook/withHeadersMock.ts *

import { Decorator } from '@storybook/react';

let allHeaders = new Map();
export const headers = () => {
  return allHeaders;
};

export const withNextHeadersMock: Decorator = (story, { parameters }) => {
  if (parameters?.nextHeaders) {
    allHeaders = new Map(Object.entries(parameters.nextHeaders));
  }

  return story();
};
// .storybook/main.ts
const config: StorybookConfig = {
  // rest of your config...
  webpackFinal: async (config) => {
    if (!config.resolve) {
      config.resolve = {};
    }

    if (!config.resolve.alias) {
      config.resolve.alias = {};
    }

    config.resolve.alias['next/headers'] = path.resolve(__dirname, './withNextHeadersMock.ts');
    return config;
  },
}

Then just add the headers you need to the story/preview

export const MyStory: Story = {
  decorators: [
    withNextHeadersMock
  ]
  parameters: {
    nextHeaders: {
      'my-header': 'foo'
    }
  }
};

This will at least give your stories the ability to run, albeit very mocked so only use this to tide over until a better solution is suggested / implemented

@shilman / @joevaugh4n Would you like me to put this into another issue so people have a temporary workaround ready?

@JamesManningR new issue would be fantastic!! โค๏ธ

Cc @valentinpalkovic

also, one for everyone here: Storybook for React Server Components ๐ŸŽŠ

@JamesManningR new issue would be fantastic!! โค๏ธ

Cc @valentinpalkovic

Created here if anyone wants to follow along

@shilman I have a question: I created these components as RSC in Next.js. However, if it's an Async Component (in my case, the Indicator component), then it renders three times in Storybook. Is this because Storybook does not yet support RSC?
image

I am using Next.js 14, storybook 7.4.2

async function Indicator() {
  console.log('render child');
  return <div>child</div>;
}

export default function Parent() {
  console.log('render parent');
  return (
    <div>
      <section>
        <h1>parent</h1>
      </section>
      <Suspense fallback={<div>Loading...</div>}>
        <Indicator />
      </Suspense>
    </div>
  );
}

Also, if the Docs of stories renders way more than I expected, what could be the reason?
image
image

@soobing I don't know what's going on there. TBH, I don't know what's going on under the hood with the Suspense component. @tmeasday any ideas on the storybook side?

If the story here is for the Parent component, then SB isn't doing anything special with the child here, so this would really just be a react question I guess. It might be worth trying the same thing in a simple React sandbox etc to see how the behaviour is there.

Also, if the Docs of stories renders way more than I expected, what could be the reason?

This one seems very surprising. If you turn logLevel: 'debug' on in your main.js and turn on all logs in the browser console, what messages does Storybook send during that docs re-rendering? Perhaps you could create a simple reproduction so we could take a look?

Hitting the same roadblocks here... Server actions are also an issue. They're still experimental and will probably change some, but it might be a good idea to keep them in mind

A server-action can be passed as a prop:

"use client"

function MyComp({changeHandler}) {
  return <select onChange={async e => {
    changeHandler(e.target.value)
  }}>...</select>
}

---

// server-side

import {doitServerSide} from './actions'
<MyComp changeHandler={doitServer} />

---

// client-side

function doitBrowserSide(val) {...}
<MyComp changeHandler={doitBrowser} />

That way you can pass a noop handler in Storybook

NB: I personally opted for a React.Context to provide the noop function to my component <Provider handler={noop}><Story /></Provider> so I don't have to change the Props signature of MyComp (but just use a hook to get the context value)
image
image