Skip to content
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

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

Closed
IGassmann opened this issue Mar 10, 2023 · 44 comments · Fixed by #25091
Closed

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

IGassmann opened this issue Mar 10, 2023 · 44 comments · Fixed by #25091

Comments

@IGassmann
Copy link

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.

@shilman
Copy link
Member

shilman commented Mar 13, 2023

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

@philwolstenholme
Copy link

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)

@evensix-dev
Copy link

evensix-dev commented May 4, 2023

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

@shilman
Copy link
Member

shilman commented May 5, 2023

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
Copy link

pm0u commented May 8, 2023

@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.

@shilman
Copy link
Member

shilman commented May 8, 2023

@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.

@TxHawks
Copy link

TxHawks commented May 12, 2023

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

@guimaferreira
Copy link

+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.

@guimaferreira
Copy link

guimaferreira commented May 31, 2023

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 
};

@icastillejogomez
Copy link

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

@shilman
Copy link
Member

shilman commented Jun 10, 2023

@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

@jinmayamashita
Copy link

jinmayamashita commented Aug 20, 2023

#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!",
  },
};

@nickserv
Copy link

nickserv commented Aug 22, 2023

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.

@sethburtonhall
Copy link

sethburtonhall commented Sep 19, 2023

+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
Copy link

kacyee commented Sep 28, 2023

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

@kyong0612
Copy link

+1 please!!! orz

@IGassmann
Copy link
Author

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.

@sethburtonhall
Copy link

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

@shilman
Copy link
Member

shilman commented Oct 30, 2023

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

@JamesManningR
Copy link

JamesManningR commented Nov 29, 2023

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
Copy link

z0n commented Nov 30, 2023

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

@jpizzati-uvl
Copy link

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
Copy link

z0n commented Nov 30, 2023

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.

@shilman
Copy link
Member

shilman commented Dec 1, 2023

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.

@joevaugh4n
Copy link
Contributor

@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!

@JamesManningR
Copy link

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
Copy link

apiel commented Dec 6, 2023

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

@philwolstenholme
Copy link

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
Copy link

apiel commented Dec 6, 2023

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 :-/

@AkifumiSato
Copy link

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.

@david-morris
Copy link

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
Copy link

z0n commented Dec 8, 2023

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.

@JamesManningR
Copy link

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

@david-morris
Copy link

david-morris commented Dec 11, 2023

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.

@JamesManningR
Copy link

JamesManningR commented Dec 13, 2023

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?

@shilman
Copy link
Member

shilman commented Dec 13, 2023

@JamesManningR new issue would be fantastic!! ❤️

Cc @valentinpalkovic

@joevaugh4n
Copy link
Contributor

thank you @JamesManningR!

@joevaugh4n
Copy link
Contributor

also, one for everyone here: Storybook for React Server Components 🎊

@JamesManningR
Copy link

@JamesManningR new issue would be fantastic!! ❤️

Cc @valentinpalkovic

Created here if anyone wants to follow along

@soobing
Copy link

soobing commented Dec 21, 2023

@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

@shilman
Copy link
Member

shilman commented Dec 27, 2023

@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?

@tmeasday
Copy link
Member

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?

@abernier
Copy link

abernier commented Jan 8, 2024

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

@kasperpeulen
Copy link
Contributor

@david-morris @JamesManningR
We have added all necessary cookie and headers mocks in 8.1. And also support module mocking with sub path imports.

For a full working demo with Prisma+Http+Cookies see this demo!
https://github.com/storybookjs/module-mocking-demo/tree/main

We recommend people using the absolute import convention with package.json imports like this, as this allows you to do module mocking in a standard based way:

// package.json
{
  "imports": {
    "#app/actions": {
      "storybook": "./app/actions.mock.ts",
      "default": "./app/actions.ts"
    },
    "#lib/session": {
      "storybook": "./lib/session.mock.ts",
      "default": "./lib/session.ts"
    },
    "#lib/db": {
      "storybook": "./lib/db.mock.ts",
      "default": "./lib/db.ts"
    },
    "#*": [
      "./*",
      "./*.ts",
      "./*.tsx"
    ]
  }
}

This is also what the Next tech lead wants to move to:
https://x.com/sebmarkbage/status/1765828741500981475?s=20

You don't have to make module mocks for your cookies anymore, as we mock that out for you!

Full example:
https://github.com/storybookjs/module-mocking-demo/blob/main/app/note/%5Bid%5D/page.stories.tsx

import { Meta, StoryObj } from '@storybook/react'
import { cookies } from '@storybook/nextjs/headers.mock'
import { http } from 'msw'
import { expect, userEvent, waitFor, within } from '@storybook/test'
import Page from './page'
import { db, initializeDB } from '#lib/db.mock'
import { createUserCookie, userCookieKey } from '#lib/session'
import { PageDecorator } from '#.storybook/decorators'
import { login } from '#app/actions.mock'
import * as auth from '#app/auth/route'

const meta = {
  component: Page,
  decorators: [PageDecorator],
  async beforeEach() {
    await db.note.create({
      data: {
        title: 'Module mocking in Storybook?',
        body: "Yup, that's a thing now! 🎉",
        createdBy: 'storybookjs',
      },
    })
    await db.note.create({
      data: {
        title: 'RSC support as well??',
        body: 'RSC is pretty cool, even cooler that Storybook supports it!',
        createdBy: 'storybookjs',
      },
    })
  },
  parameters: {
    layout: 'fullscreen',
    nextjs: {
      navigation: {
        pathname: '/note/[id]',
        query: { id: '1' },
      },
    },
  },
  args: { params: { id: '1' } },
} satisfies Meta<typeof Page>

export default meta

type Story = StoryObj<typeof meta>

export const LoggedIn: Story = {
  async beforeEach() {
    cookies().set(userCookieKey, await createUserCookie('storybookjs'))
  },
}

export const NotLoggedIn: Story = {}

export const LoginShouldGetOAuthTokenAndSetCookie: Story = {
  parameters: {
    msw: {
      // Mock out OAUTH
      handlers: [
        http.post(
          'https://github.com/login/oauth/access_token',
          async ({ request }) => {
            let json = (await request.json()) as any
            return Response.json({ access_token: json.code })
          },
        ),
        http.get('https://api.github.com/user', async ({ request }) =>
          Response.json({
            login: request.headers.get('Authorization')?.replace('token ', ''),
          }),
        ),
      ],
    },
  },
  beforeEach() {
    // Point the login implementation to the endpoint github would have redirected too.
    login.mockImplementation(async () => {
      return await auth.GET(new Request('/auth?code=storybookjs'))
    })
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    await expect(cookies().get(userCookieKey)?.value).toBeUndefined()
    await userEvent.click(
      await canvas.findByRole('menuitem', { name: /login to add/i }),
    )
    await waitFor(async () => {
      await expect(cookies().get(userCookieKey)?.value).toContain('storybookjs')
    })
  },
}

export const LogoutShouldDeleteCookie: Story = {
  async beforeEach() {
    cookies().set(userCookieKey, await createUserCookie('storybookjs'))
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    await expect(cookies().get(userCookieKey)?.value).toContain('storybookjs')
    await userEvent.click(await canvas.findByRole('button', { name: 'logout' }))
    await expect(cookies().get(userCookieKey)).toBeUndefined()
  },
}

export const SearchInputShouldFilterNotes: Story = {
  parameters: {
    nextjs: {
      navigation: {
        query: { q: 'RSC' },
      },
    },
  },
}

export const EmptyState: Story = {
  async beforeEach() {
    initializeDB({}) // init an empty DB
  },
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.