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

fix(react): Change Renderer from createRoot.render to renderToReadableStream & hydrateRoot #30419

Open
wants to merge 3 commits into
base: next
Choose a base branch
from

Conversation

NEKOYASAN
Copy link

@NEKOYASAN NEKOYASAN commented Jan 29, 2025

Closes #30317

What I did

Rendering asynchronous RSC with Suspense in Client Render causes a re-rendering loop.
Change from createRoot.render to using renderToReadableStream and hydrateRoot to avoid re-rendering loop.
This change only applies to those with parameters.react.rsc === true.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

  1. Run a sandbox nextjs/default-ts
  2. Open Storybook in browser
  3. Access stories/frameworks/nextjs/rsc/Default
  4. Access stories/frameworks/nextjs/rsc/WithAsyncFunctionRSC <- Impacted step in this PR

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook canary-release-pr.yml --field pr=<PR_NUMBER>

name before after diff z %
createSize 0 B 0 B 0 B - -
generateSize 80.5 MB 80.5 MB 0 B 0.45 0%
initSize 80.5 MB 140 MB 59.9 MB 77.85 🔺42.6%
diffSize 97 B 59.9 MB 59.9 MB Infinity 🔺100%
buildSize 7.31 MB 7.36 MB 52.3 kB 4.78 0.7%
buildSbAddonsSize 1.9 MB 1.85 MB -50.4 kB -3.28 -2.7%
buildSbCommonSize 195 kB 195 kB 0 B - 0%
buildSbManagerSize 1.88 MB 1.86 MB -11.4 kB -9.17 -0.6%
buildSbPreviewSize 0 B 0 B 0 B - -
buildStaticSize 0 B 0 B 0 B - -
buildPrebuildSize 3.97 MB 3.91 MB -61.8 kB -4.69 -1.6%
buildPreviewSize 3.34 MB 3.46 MB 114 kB 8.87 3.3%
testBuildSize 0 B 0 B 0 B - -
testBuildSbAddonsSize 0 B 0 B 0 B - -
testBuildSbCommonSize 0 B 0 B 0 B - -
testBuildSbManagerSize 0 B 0 B 0 B - -
testBuildSbPreviewSize 0 B 0 B 0 B - -
testBuildStaticSize 0 B 0 B 0 B - -
testBuildPrebuildSize 0 B 0 B 0 B - -
testBuildPreviewSize 0 B 0 B 0 B - -
name before after diff z %
createTime 7.2s 7.4s 213ms -0.94 2.9%
generateTime 18.6s 19.3s 671ms 0 3.5%
initTime 4.3s 13.5s 9.2s 26.4 🔺67.8%
buildTime 8.7s 10.1s 1.4s 1.01 14.1%
testBuildTime 0ms 0ms 0ms - -
devPreviewResponsive 5.5s 5.4s -118ms -0.18 -2.2%
devManagerResponsive 3.9s 4.1s 227ms 0.02 5.5%
devManagerHeaderVisible 701ms 812ms 111ms 0.07 13.7%
devManagerIndexVisible 708ms 872ms 164ms 0.25 18.8%
devStoryVisibleUncached 4.8s 4.2s -670ms 0.83 -15.9%
devStoryVisible 792ms 873ms 81ms 0.02 9.3%
devAutodocsVisible 719ms 652ms -67ms -0.87 -10.3%
devMDXVisible 783ms 739ms -44ms -0.16 -6%
buildManagerHeaderVisible 826ms 718ms -108ms -0.42 -15%
buildManagerIndexVisible 931ms 822ms -109ms -0.44 -13.3%
buildStoryVisible 812ms 705ms -107ms -0.37 -15.2%
buildAutodocsVisible 597ms 670ms 73ms -0.23 10.9%
buildMDXVisible 860ms 540ms -320ms -0.91 -59.3%

Greptile Summary

This PR modifies the React Server Components (RSC) rendering approach to fix infinite re-rendering issues with async components.

  • Added renderElementExperimentalRSC in code/lib/react-dom-shim/src/react-18.tsx to use renderToReadableStream and hydrateRoot instead of createRoot.render
  • Modified renderToCanvas to conditionally use RSC rendering based on storyContext?.parameters?.react?.rsc
  • Added test story WithAsyncFunctionRSC in RSC.stories.tsx with 1-second delay to validate fix
  • Added WithAsyncFunction component in RSC.tsx to demonstrate async RSC functionality

The changes look appropriate and focused on solving the infinite re-rendering issue while maintaining proper error handling and state management. The test coverage with both sync and async RSC components helps validate the fix.

💡 (1/5) You can manually trigger the bot by mentioning @greptileai in a comment!

@NEKOYASAN NEKOYASAN force-pushed the fix/30317-migrate-renderer-to-readable-stream-hydrate branch 3 times, most recently from 7983529 to 6b4230a Compare January 31, 2025 17:08
@NEKOYASAN NEKOYASAN marked this pull request as ready for review January 31, 2025 18:00
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 file(s) reviewed, 3 comment(s)
Edit PR Review Bot Settings | Greptile

Comment on lines +9 to +10
export const WithAsyncFunction = async ({ children }: { children: any }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: children prop should be properly typed instead of using 'any'

Suggested change
export const WithAsyncFunction = async ({ children }: { children: any }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
export const WithAsyncFunction = async ({ children }: { children: React.ReactNode }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));

Comment on lines +104 to +107
const stream = await ReactDOMServer.renderToReadableStream(node, {
// @ts-expect-error onCaughtError in hydrationRoot and createRoot (React 19 only)
onError: rootOptions?.onCaughtError,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The onError handler is marked as React 19 only but no version check is performed. This could cause runtime errors in React 18.

Comment on lines +98 to +102
let root = nodes.get(el);

if (root) {
root.unmount();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Unmounting the existing root before the stream is ready could cause a flash of empty content.

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

4 file(s) reviewed, no comment(s)
Edit PR Review Bot Settings | Greptile

@NEKOYASAN
Copy link
Author

NEKOYASAN commented Jan 31, 2025

Failed 3 e2e tests Resolved!

Test logs ``` > yarn playwright test

Running 152 tests using 4 workers

  1. [chromium] › module-mocking.spec.ts:15:7 › module-mocking › should assert story lifecycle order
Error: expect(received).toMatch(expected)

Expected substring: "7 - [from onClick]"
Received string:    "▶console.log: (1) [\"5 - [from decorator]\"]"

  37 |     for (let i = 0; i < expectedTexts.length; i++) {
  38 |       const nthText = await logItem.locator(`li >> nth=${i}`).innerText();
> 39 |       expect(nthText).toMatch(expectedTexts[i]);
     |                       ^
  40 |     }
  41 |   });
  42 |

    at /Users/nekoyasan/Projects/storybook/code/e2e-tests/module-mocking.spec.ts:39:23
  1. [firefox] › addon-docs.spec.ts:113:7 › addon-docs › source snippet should not change in stories block
Error: Timed out 5000ms waiting for expect(locator).toContainText(expected)

Locator: locator('#storybook-preview-iframe').contentFrame().locator('#storybook-root:visible, #storybook-docs:visible').locator('pre.prismjs').first()
- Expected string  - 1
+ Received string  + 4

- Changed
+ <Button
+   label="Basic"
+   onClick={() => {}}
+ />
Call log:
  - expect.toContainText with timeout 5000ms
  - waiting for locator('#storybook-preview-iframe').contentFrame().locator('#storybook-root:visible, #storybook-docs:visible').locator('pre.prismjs').first()
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"
  -   locator resolved to <pre class="prismjs css-4zr3vl">…</pre>
  -   unexpected value "<Button
  label="Basic"
  onClick={() => {}}
/>"


  152 |
  153 |     // Check the Primary one has changed
> 154 |     await expect(primaryCode).toContainText('Changed');
      |                               ^
  155 |     // Check the stories one still says "Basic"
  156 |     await expect(storiesCode).toContainText('Basic');
  157 |   });

    at /Users/nekoyasan/Projects/storybook/code/e2e-tests/addon-docs.spec.ts:154:31
  1. [firefox] › module-mocking.spec.ts:15:7 › module-mocking › should assert story lifecycle order
Error: expect(received).toMatch(expected)

Expected substring: "7 - [from onClick]"
Received string:    "▶console.log: (1) [\"5 - [from decorator]\"]"

  37 |     for (let i = 0; i < expectedTexts.length; i++) {
  38 |       const nthText = await logItem.locator(`li >> nth=${i}`).innerText();
> 39 |       expect(nthText).toMatch(expectedTexts[i]);
     |                       ^
  40 |     }
  41 |   });
  42 |

    at /Users/nekoyasan/Projects/storybook/code/e2e-tests/module-mocking.spec.ts:39:23

3 failed
[chromium] › module-mocking.spec.ts:15:7 › module-mocking › should assert story lifecycle order
[firefox] › addon-docs.spec.ts:113:7 › addon-docs › source snippet should not change in stories block
[firefox] › module-mocking.spec.ts:15:7 › module-mocking › should assert story lifecycle order ─
37 skipped
112 passed (1.6m)

</summary>

@valentinpalkovic valentinpalkovic self-assigned this Feb 3, 2025
@valentinpalkovic valentinpalkovic added the ci:daily Run the CI jobs that normally run in the daily job. label Feb 12, 2025
@storybook-pr-benchmarking
Copy link

storybook-pr-benchmarking bot commented Feb 12, 2025

Package Benchmarks

Commit: 5d08f1d, ran on 20 February 2025 at 09:36:23 UTC

The following packages have significant changes to their size or dependencies:

@storybook/vue3-vite

Before After Difference
Dependency count 108 108 0
Self size 17 KB 17 KB 0 B
Dependency size 42.63 MB 42.61 MB 🎉 -18 KB 🎉
Bundle Size Analyzer Link Link

@storybook/react-dom-shim

Before After Difference
Dependency count 0 0 0
Self size 10 KB 11 KB 🚨 +1 KB 🚨
Dependency size 792 B 792 B 0 B
Bundle Size Analyzer Link Link

@shilman shilman added the rsc label Feb 18, 2025
@FezVrasta
Copy link
Contributor

What's blocking this PR? Any way to help?

@NEKOYASAN
Copy link
Author

@FezVrasta
Thank you for your concern!

I was too busy with my day job to work on it.
This weekend is a three-day weekend in Japan, so I plan to be able to work there.

The first thing I need to fix with this PR is that this e2e test fails, and I'm having trouble figuring out how to fix the should assert story lifecycle order in particular.
I think the Decorator is being called twice because the Renderer is using renderToReadableStream and hydrationRoot like SSR.
If Decorator has already been executed, it may be necessary to devise a way not to execute it on the Client Side, etc. However, as a lifecycle order, it is correct as it is now, and I am not sure what to do.

@FezVrasta
Copy link
Contributor

FezVrasta commented Feb 19, 2025

Thanks for the write up. Yes the double decorator call is definitely because it's first called during rendering and then during hydration.

I think it would make sense to update the test to account for this?

Alternatively rather than checking if the decorator is called, the test could check if some client-side only code is executed (for example a spy called in a useEffect).

@NEKOYASAN
Copy link
Author

Thank you very much.
I was wondering if it would not be a good idea to change the test for this case for RSC, but I thought surely using useEffect would be a good way to make sure it is working only on the client side! I will try to fix the test case that way.

I'm also a little concerned about the source snippet should not change in stories block test case, but since it's Firefox only, I haven't figured out what the problem is yet.
Also, since the DOM structure changes with createRoot, there is a possibility that the Snapshot Test may show differences due to updates, so it may be necessary to add something to the documentation to address this.

@NEKOYASAN NEKOYASAN force-pushed the fix/30317-migrate-renderer-to-readable-stream-hydrate branch from 0d5b5f9 to 5d08f1d Compare February 20, 2025 09:26
@NEKOYASAN
Copy link
Author

NEKOYASAN commented Feb 20, 2025

@FezVrasta Thank you so much for your advice.
I rebased to the latest commit and everything now passes the e2e test (except the source snippet should not change in stories block in firefox).

I have three questions. If anyone knows anything about this, please let me know.

  1. It seems that ci/circleci: create-sandboxes is causing an error due to Angular, but I haven't touched anything related to Angular, so what do you know?
  2. ci/circleci: test-portable-stories-react is in error because the TextEncoder that react-dom/server.browser depends on internally does not exist, but jest(jsdom) does not support this, I'm having trouble figuring out how to deal with this.I am thinking of writing it in the SETUP file, but I am wondering if there is a better way to do it as it might affect the users.
  3. I have an unexpected difference due to code/lib/cli-storybook/bin/index.cjs automigrate csf-factories being executed in the task, should this be included in the PR?

@FezVrasta
Copy link
Contributor

FezVrasta commented Feb 20, 2025

I'm testing your patch on a large project and many stories are failing with Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported., the project uses the nextjs addon and next.js 15.0.4

This error is explained quite clearly on this blog post https://dev.to/codewithvick/how-to-fix-the-detected-multiple-renderers-concurrently-rendering-the-same-context-provider-4o09

I'm not sure how to solve it though.

@NEKOYASAN
Copy link
Author

I had this happen before with Sandbox and fix it...

My storybook environment is not working in storybook dev, so I'll check again as I deal with that.

@FezVrasta
Copy link
Contributor

Can you share some context on how did you fix it earlier? I don't quite understand why this error is happening, your code looks good to me, you take the react node, render it "server side", put its result in the node html, and then hydrate it.

@NEKOYASAN
Copy link
Author

There was due to the fact that multiple renderers were created by not unmounting correctly when re-rendering.
As for that, I solved it by always unmounting before rendering.

https://github.com/storybookjs/storybook/pull/30419/files#diff-1ad505435984d7e5d2db259fa8f1917635f5129d70f88ec48d4b3245e628e54cR98-R102

Since renderToReadableStream is used in this fix, it rewrites element.innerHTML directly.
This may cause a situation in which the root of the unmount destination cannot be obtained correctly because the keys in the Map do not match.

https://github.com/storybookjs/storybook/pull/30419/files#diff-1ad505435984d7e5d2db259fa8f1917635f5129d70f88ec48d4b3245e628e54cR110

@FezVrasta
Copy link
Contributor

Do you think a queue could help? Where we wait for the previous renderToReadableStream to complete before a new one starts? Or maybe we should completely abort the previous operation if a new one is incoming?

@FezVrasta
Copy link
Contributor

I tried a quick and dirty queue approach but it doesn't solve the issue 🤔

diff --git a/code/lib/react-dom-shim/src/react-18.tsx b/code/lib/react-dom-shim/src/react-18.tsx
index e7a131fa40..d4712cbe59 100644
--- a/code/lib/react-dom-shim/src/react-18.tsx
+++ b/code/lib/react-dom-shim/src/react-18.tsx
@@ -5,6 +5,50 @@ import type { Root as ReactRoot, RootOptions } from 'react-dom/client';
 import * as ReactDOM from 'react-dom/client';
 import * as ReactDOMServer from 'react-dom/server';
 
+class PromiseQueue {
+  private queue: (() => Promise<any>)[] = [];
+
+  private isProcessing: boolean = false;
+
+  // Method to add a new task to the queue
+  public add(task: () => Promise<any>): Promise<any> {
+    return new Promise((resolve, reject) => {
+      this.queue.push(() => task().then(resolve).catch(reject));
+      this.processQueue();
+    });
+  }
+
+  // Method to process the queue
+  private async processQueue(): Promise<void> {
+    if (this.isProcessing) {
+      return;
+    } // Prevent multiple processing // Prevent multiple processing
+
+    this.isProcessing = true;
+
+    while (this.queue.length > 0) {
+      const task = this.queue.shift(); // Get the next task
+      if (task) {
+        try {
+          await task(); // Execute the task
+        } catch (error) {
+          console.error('Task failed:', error);
+        }
+      }
+    }
+
+    this.isProcessing = false; // Reset processing state
+  }
+}
+
+// Create an instance of the queue
+const queue = new PromiseQueue();
+
+// Function to run tasks in the queue
+async function runInQueue(task: () => Promise<any>): Promise<any> {
+  return queue.add(task);
+}
+
 // A map of all rendered React 18 nodes
 const nodes = new Map<Element, ReactRoot>();
 
@@ -53,7 +97,7 @@ export const renderElement = async (
   experimentalRSCRenderer?: boolean
 ) => {
   if (experimentalRSCRenderer) {
-    await renderElementExperimentalRSC(node, el, rootOptions);
+    await runInQueue(() => renderElementExperimentalRSC(node, el, rootOptions));
     return;
   }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug ci:daily Run the CI jobs that normally run in the daily job. nextjs react rsc
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug]: Infinite re-render for async RSC stories
4 participants