From d7194ee05aa8725c3704f0629b7e10c4f004692f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 21 Jul 2025 09:30:48 +0900 Subject: [PATCH 1/8] fix: mention React.use SSR bug workaround --- packages/plugin-rsc/examples/starter/src/client.tsx | 8 ++++++++ packages/plugin-rsc/examples/starter/src/root.tsx | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/examples/starter/src/client.tsx b/packages/plugin-rsc/examples/starter/src/client.tsx index 29bb5d367..010fc2cc9 100644 --- a/packages/plugin-rsc/examples/starter/src/client.tsx +++ b/packages/plugin-rsc/examples/starter/src/client.tsx @@ -11,3 +11,11 @@ export function ClientCounter() { ) } + +const promise = Promise.resolve('ok') + +export function TestClientUse() { + const value = React.use(promise) + console.log(value) + return TestClientUse: {value} +} diff --git a/packages/plugin-rsc/examples/starter/src/root.tsx b/packages/plugin-rsc/examples/starter/src/root.tsx index a42c6a92a..af9363da8 100644 --- a/packages/plugin-rsc/examples/starter/src/root.tsx +++ b/packages/plugin-rsc/examples/starter/src/root.tsx @@ -2,7 +2,7 @@ import './index.css' // css import is automatically injected in exported server import viteLogo from '/vite.svg' import { getServerCounter, updateServerCounter } from './action.tsx' import reactLogo from './assets/react.svg' -import { ClientCounter } from './client.tsx' +import { ClientCounter, TestClientUse } from './client.tsx' export function Root() { return ( @@ -14,7 +14,8 @@ export function Root() { Vite + RSC - + + {/* */} ) From 9d92f8cb2979ca0d27ced74dbab4e6f13d22f4c3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 21 Jul 2025 09:40:05 +0900 Subject: [PATCH 2/8] fix: add SsrUseWorkaround --- .../examples/starter/src/framework/entry.ssr.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx index 129dbadf1..018b77a26 100644 --- a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx @@ -24,7 +24,15 @@ export async function renderHTML( // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work payload ??= ReactClient.createFromReadableStream(rscStream1) - return React.use(payload).root + const root = React.use(payload).root + return {root} + } + + // Add an empty component in between SsrRoot and user's root to avoid React SSR bugs. + // > SsrRoot (use) -> SsrUseWorkaround -> root (which potentially has `lazy` + `use`) + // https://github.com/facebook/react/issues/33937#issuecomment-3091349011 + function SsrUseWorkaround(props: React.PropsWithChildren) { + return props.children } // render html (traditional SSR) From 89a1aea9c86940f82b093155baaab7f03ce4916a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 22 Jul 2025 10:22:09 +0900 Subject: [PATCH 3/8] chore: tweak --- .../examples/starter/src/framework/entry.ssr.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx index 432e0ea59..6af9bf547 100644 --- a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx @@ -19,19 +19,21 @@ export async function renderHTML( const [rscStream1, rscStream2] = rscStream.tee() // deserialize RSC stream back to React VDOM - let payload: Promise + let payload: Promise | undefined function SsrRoot() { // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work payload ??= ReactClient.createFromReadableStream(rscStream1) const root = React.use(payload).root - return {root} + return {root} } - // Add an empty component in between SsrRoot and user's root to avoid React SSR bugs. - // > SsrRoot (use) -> SsrUseWorkaround -> root (which potentially has `lazy` + `use`) + // Add an empty component in between `SsrRoot` and user `root` to avoid React SSR bugs. + // SsrRoot (use) + // => FixSsrThenable + // => root (which potentially has `lazy` + `use`) // https://github.com/facebook/react/issues/33937#issuecomment-3091349011 - function SsrUseWorkaround(props: React.PropsWithChildren) { + function FixSsrThenable(props: React.PropsWithChildren) { return props.children } From 483e40b4648db7c67477f0bfbea6daef237e62c9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 22 Jul 2025 10:50:26 +0900 Subject: [PATCH 4/8] test: add e2e --- packages/plugin-rsc/e2e/fixture.ts | 2 +- packages/plugin-rsc/e2e/helper.ts | 12 +++++ packages/plugin-rsc/e2e/starter.test.ts | 59 +++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 4c927a271..ac1d7385d 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -217,7 +217,7 @@ export async function setupInlineFixture(options: { let filepath = path.join(options.dest, filename) fs.mkdirSync(path.dirname(filepath), { recursive: true }) // strip indent - contents = contents.replace(/^\n/, '') + contents = contents.replace(/^\n*/, '').replace(/\s*$/, '\n') const indent = contents.match(/^\s*/)?.[0] ?? '' const strippedContents = contents .split('\n') diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts index b1b167e38..702c5b7ec 100644 --- a/packages/plugin-rsc/e2e/helper.ts +++ b/packages/plugin-rsc/e2e/helper.ts @@ -42,3 +42,15 @@ export async function expectNoReload(page: Page) { }, } } + +export function expectNoPageError(page: Page) { + const errors: Error[] = [] + page.on('pageerror', (error) => { + errors.push(error) + }) + return { + [Symbol.dispose]: () => { + expect(errors).toEqual([]) + }, + } +} diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index 8b1958471..ea7ca8b37 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test' import { setupInlineFixture, type Fixture, useFixture } from './fixture' import { + expectNoPageError, expectNoReload, testNoJs, waitForHydration as waitForHydration_, @@ -203,6 +204,64 @@ test.describe(() => { }) }) +test.describe(() => { + const root = 'examples/e2e/temp/ssr-thenable' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/root.tsx': /* tsx */ ` + import { TestClientUse } from './client.tsx' + + export function Root() { + return ( + + + + + + + + + ) + } + `, + 'src/client.tsx': /* tsx */ ` + "use client"; + import React from 'react' + + const promise = Promise.resolve('ok') + + export function TestClientUse() { + const value = React.use(promise) + return {value} + } + `, + }, + }) + }) + + function defineSsrThenableTest(f: Fixture) { + test('ssr-thenable', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration_(page) + }) + } + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineSsrThenableTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineSsrThenableTest(f) + }) +}) + function defineTest(f: Fixture, variant?: 'no-ssr') { const waitForHydration: typeof waitForHydration_ = (page) => waitForHydration_(page, variant === 'no-ssr' ? '#root' : 'body') From 22f06064bf85214376285dbf92ebcafbe9704164 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 22 Jul 2025 10:50:59 +0900 Subject: [PATCH 5/8] chore: cleanup --- packages/plugin-rsc/examples/starter/src/client.tsx | 8 -------- packages/plugin-rsc/examples/starter/src/root.tsx | 5 ++--- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/plugin-rsc/examples/starter/src/client.tsx b/packages/plugin-rsc/examples/starter/src/client.tsx index 010fc2cc9..29bb5d367 100644 --- a/packages/plugin-rsc/examples/starter/src/client.tsx +++ b/packages/plugin-rsc/examples/starter/src/client.tsx @@ -11,11 +11,3 @@ export function ClientCounter() { ) } - -const promise = Promise.resolve('ok') - -export function TestClientUse() { - const value = React.use(promise) - console.log(value) - return TestClientUse: {value} -} diff --git a/packages/plugin-rsc/examples/starter/src/root.tsx b/packages/plugin-rsc/examples/starter/src/root.tsx index af9363da8..a42c6a92a 100644 --- a/packages/plugin-rsc/examples/starter/src/root.tsx +++ b/packages/plugin-rsc/examples/starter/src/root.tsx @@ -2,7 +2,7 @@ import './index.css' // css import is automatically injected in exported server import viteLogo from '/vite.svg' import { getServerCounter, updateServerCounter } from './action.tsx' import reactLogo from './assets/react.svg' -import { ClientCounter, TestClientUse } from './client.tsx' +import { ClientCounter } from './client.tsx' export function Root() { return ( @@ -14,8 +14,7 @@ export function Root() { Vite + RSC - - {/* */} + ) From 72515f988faabbf6c7e9f3766bd183758bae3682 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 22 Jul 2025 10:54:57 +0900 Subject: [PATCH 6/8] test: tweak --- packages/plugin-rsc/e2e/ssr-thenable.test.ts | 64 ++++++++++++++++++++ packages/plugin-rsc/e2e/starter.test.ts | 59 ------------------ 2 files changed, 64 insertions(+), 59 deletions(-) create mode 100644 packages/plugin-rsc/e2e/ssr-thenable.test.ts diff --git a/packages/plugin-rsc/e2e/ssr-thenable.test.ts b/packages/plugin-rsc/e2e/ssr-thenable.test.ts new file mode 100644 index 000000000..7bf9e14e4 --- /dev/null +++ b/packages/plugin-rsc/e2e/ssr-thenable.test.ts @@ -0,0 +1,64 @@ +import { test } from '@playwright/test' +import { setupInlineFixture, type Fixture, useFixture } from './fixture' +import { + expectNoPageError, + waitForHydration as waitForHydration_, +} from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/ssr-thenable' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/root.tsx': /* tsx */ ` + import { TestClientUse } from './client.tsx' + + export function Root() { + return ( + + + + + + + + + ) + } + `, + 'src/client.tsx': /* tsx */ ` + "use client"; + import React from 'react' + + const promise = Promise.resolve('ok') + + export function TestClientUse() { + const value = React.use(promise) + return {value} + } + `, + }, + }) + }) + + function defineSsrThenableTest(f: Fixture) { + test('ssr-thenable', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration_(page) + }) + } + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineSsrThenableTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineSsrThenableTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index ea7ca8b37..8b1958471 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test' import { setupInlineFixture, type Fixture, useFixture } from './fixture' import { - expectNoPageError, expectNoReload, testNoJs, waitForHydration as waitForHydration_, @@ -204,64 +203,6 @@ test.describe(() => { }) }) -test.describe(() => { - const root = 'examples/e2e/temp/ssr-thenable' - - test.beforeAll(async () => { - await setupInlineFixture({ - src: 'examples/starter', - dest: root, - files: { - 'src/root.tsx': /* tsx */ ` - import { TestClientUse } from './client.tsx' - - export function Root() { - return ( - - - - - - - - - ) - } - `, - 'src/client.tsx': /* tsx */ ` - "use client"; - import React from 'react' - - const promise = Promise.resolve('ok') - - export function TestClientUse() { - const value = React.use(promise) - return {value} - } - `, - }, - }) - }) - - function defineSsrThenableTest(f: Fixture) { - test('ssr-thenable', async ({ page }) => { - using _ = expectNoPageError(page) - await page.goto(f.url()) - await waitForHydration_(page) - }) - } - - test.describe('dev', () => { - const f = useFixture({ root, mode: 'dev' }) - defineSsrThenableTest(f) - }) - - test.describe('build', () => { - const f = useFixture({ root, mode: 'build' }) - defineSsrThenableTest(f) - }) -}) - function defineTest(f: Fixture, variant?: 'no-ssr') { const waitForHydration: typeof waitForHydration_ = (page) => waitForHydration_(page, variant === 'no-ssr' ? '#root' : 'body') From f71f40cfab2ff32f00b862d2bdcd5e17b196a600 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 22 Jul 2025 10:58:43 +0900 Subject: [PATCH 7/8] test: add to examples/basic too --- .../plugin-rsc/examples/basic/src/framework/entry.ssr.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx index 0fd5e92f3..2c7abbc76 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx @@ -24,7 +24,11 @@ export async function renderHTML( // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work payload ??= ReactClient.createFromReadableStream(rscStream1) - return React.use(payload).root + return {React.use(payload).root} + } + + function FixSsrThenable(props: React.PropsWithChildren) { + return props.children } // render html (traditional SSR) From a095b3104d10e6a8619afa73eb30a0ff04007e59 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 22 Jul 2025 10:59:51 +0900 Subject: [PATCH 8/8] chore: tweak --- .../plugin-rsc/examples/starter/src/framework/entry.ssr.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx index 6af9bf547..8ce5f4e11 100644 --- a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx @@ -24,8 +24,7 @@ export async function renderHTML( // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work payload ??= ReactClient.createFromReadableStream(rscStream1) - const root = React.use(payload).root - return {root} + return {React.use(payload).root} } // Add an empty component in between `SsrRoot` and user `root` to avoid React SSR bugs.