From 1a0b60909c2c0af11c72c5cda92286de8add73e9 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 2 Oct 2026 15:50:44 +0100 Subject: [PATCH 1/4] test(rsc): fix HMR edit races in e2e - Space out editor writes to the same file. Vite's chokidar watcher drops a `change` event within 50ms of the previous one, so a fast HMR round trip could swallow `editor.reset()` (e.g. `dev-no-ssr > client hmr`). - Wait for the `rsc:update` refetch before reloading in `use-cache-persistent`, so the reload neither aborts it (unhandled `Load failed` in WebKit) nor receives the late update before hydration (`setPayload is not a function`). --- packages/plugin-rsc/e2e/fixture.ts | 25 ++++++++++++++++--- .../e2e/use-cache-persistent.test.ts | 25 ++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 5835e57d9..9e90d888a 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -159,12 +159,29 @@ export function useFixture(options: { } } +// Vite's file watcher (chokidar) drops a `change` event that arrives within +// 50ms of the previous one for the same path. A fast HMR round trip can make +// an edit followed by a reset land inside that window, so Vite never sees the +// reset. Space out writes to the same file to keep every change observable. +const WRITE_INTERVAL_MS = 100 +const lastWriteTimes = new Map() + +function writeWatchedFile(filepath: string, content: string) { + const wait = + (lastWriteTimes.get(filepath) ?? 0) + WRITE_INTERVAL_MS - Date.now() + if (wait > 0) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait) + } + fs.writeFileSync(filepath, content) + lastWriteTimes.set(filepath, Date.now()) +} + export function useCreateEditor(cwd: string) { const originalFiles: Record = {} test.afterAll(async () => { for (const [filepath, content] of Object.entries(originalFiles)) { - fs.writeFileSync(filepath, content) + writeWatchedFile(filepath, content) } }) @@ -179,13 +196,13 @@ export function useCreateEditor(cwd: string) { const next = editFn(current) assert(next !== current, 'Edit function did not change the content') current = next - fs.writeFileSync(filepath, next) + writeWatchedFile(filepath, next) }, reset(): void { - fs.writeFileSync(filepath, originalFiles[filepath]!) + writeWatchedFile(filepath, originalFiles[filepath]!) }, resave(): void { - fs.writeFileSync(filepath, current) + writeWatchedFile(filepath, current) }, } } diff --git a/packages/plugin-rsc/e2e/use-cache-persistent.test.ts b/packages/plugin-rsc/e2e/use-cache-persistent.test.ts index 186b4f72e..e30f400ad 100644 --- a/packages/plugin-rsc/e2e/use-cache-persistent.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-persistent.test.ts @@ -112,7 +112,9 @@ function defineDevTests(f: Fixture) { await expect(executionCount).toHaveText('1') // Editing the cached function advances its module generation. - action.edit((code) => code.replace('body-v1', 'body-v2')) + await editServerModule(page, () => + action.edit((code) => code.replace('body-v1', 'body-v2')), + ) await page.reload() await waitForHydration(page) await expectResultAfterUpdate( @@ -123,7 +125,9 @@ function defineDevTests(f: Fixture) { ) // Editing a direct dependency invalidates the importing cache module. - direct.edit((code) => code.replace('direct-v1', 'direct-v2')) + await editServerModule(page, () => + direct.edit((code) => code.replace('direct-v1', 'direct-v2')), + ) await page.reload() await waitForHydration(page) await expectResultAfterUpdate( @@ -134,7 +138,9 @@ function defineDevTests(f: Fixture) { ) // Reverse-importer traversal also reaches transitive dependencies. - transitive.edit((code) => code.replace('transitive-v1', 'transitive-v2')) + await editServerModule(page, () => + transitive.edit((code) => code.replace('transitive-v1', 'transitive-v2')), + ) await page.reload() await waitForHydration(page) await expectResultAfterUpdate( @@ -159,6 +165,19 @@ function defineDevTests(f: Fixture) { }) } +// Editing a server module makes the page refetch its RSC payload through +// `rsc:update`. Wait for that refetch before reloading. Otherwise the reload +// can abort it (an unhandled `TypeError: Load failed` in WebKit), or the late +// update can reach the reloaded page before it has hydrated. +async function editServerModule(page: Page, edit: () => void) { + const refetch = page.waitForResponse( + (response) => + response.request().method() === 'GET' && response.url().includes('_.rsc'), + ) + edit() + await (await refetch).finished() +} + async function expectResultAfterUpdate( page: Page, form: Locator, From f74bb8e13c459a978e23d4584e244e4c3a3338be Mon Sep 17 00:00:00 2001 From: James Date: Fri, 2 Oct 2026 16:39:17 +0100 Subject: [PATCH 2/4] test(rsc): allow 60s for isolated fixture installs `setupIsolatedFixture` runs `pnpm i` over the network inside a `beforeAll` hook. On macOS runners installs take 16-34s, and the `react-server-dom-webpack` fixture installs twice, so the hook kept hitting the default 30s timeout. --- packages/plugin-rsc/e2e/fixture.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 9e90d888a..9e013e6b7 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -215,6 +215,10 @@ export async function setupIsolatedFixture(options: { dest: string overrides?: Record }) { + // `pnpm i` hits the network and can take over 30s on macOS runners, which + // would exceed the default timeout of the `beforeAll` hook calling this. + test.setTimeout(60_000) + // copy fixture fs.rmSync(options.dest, { recursive: true, force: true }) fs.cpSync(options.src, options.dest, { From f9d69d46f2eda84b24914f57b0726d1d129c3416 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:58:10 +0900 Subject: [PATCH 3/4] test(rsc): lead e2e deflake comments with what they do Co-Authored-By: Claude Opus 5.5 --- packages/plugin-rsc/e2e/fixture.ts | 11 +++++------ packages/plugin-rsc/e2e/use-cache-persistent.test.ts | 8 ++++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 9e013e6b7..3f08ff2fa 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -159,10 +159,9 @@ export function useFixture(options: { } } -// Vite's file watcher (chokidar) drops a `change` event that arrives within -// 50ms of the previous one for the same path. A fast HMR round trip can make -// an edit followed by a reset land inside that window, so Vite never sees the -// reset. Space out writes to the same file to keep every change observable. +// Space out writes to the same file by at least 100ms so Vite sees each one. +// Its watcher (chokidar) drops a `change` event within 50ms of the previous +// one for the same path, so a fast edit-then-reset could lose the reset. const WRITE_INTERVAL_MS = 100 const lastWriteTimes = new Map() @@ -215,8 +214,8 @@ export async function setupIsolatedFixture(options: { dest: string overrides?: Record }) { - // `pnpm i` hits the network and can take over 30s on macOS runners, which - // would exceed the default timeout of the `beforeAll` hook calling this. + // Allow 60s for the calling `beforeAll` hook, because `pnpm i` hits the + // network and can exceed the default 30s on macOS runners. test.setTimeout(60_000) // copy fixture diff --git a/packages/plugin-rsc/e2e/use-cache-persistent.test.ts b/packages/plugin-rsc/e2e/use-cache-persistent.test.ts index e30f400ad..1a9b346f4 100644 --- a/packages/plugin-rsc/e2e/use-cache-persistent.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-persistent.test.ts @@ -165,10 +165,10 @@ function defineDevTests(f: Fixture) { }) } -// Editing a server module makes the page refetch its RSC payload through -// `rsc:update`. Wait for that refetch before reloading. Otherwise the reload -// can abort it (an unhandled `TypeError: Load failed` in WebKit), or the late -// update can reach the reloaded page before it has hydrated. +// Edit a server module and wait for the `rsc:update` refetch it triggers. +// Reloading before that finishes can abort the refetch (an unhandled +// `TypeError: Load failed` in WebKit), or let the late update reach the +// reloaded page before it has hydrated. async function editServerModule(page: Page, edit: () => void) { const refetch = page.waitForResponse( (response) => From 17be196c9416c411cc5a94ccaa7894c4fb1991c5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:11:12 +0900 Subject: [PATCH 4/4] test(rsc): move e2e write spacing to #1489 The editor write-spacing fix moves to its own PR, which makes the editor API async instead of sleeping synchronously. Co-Authored-By: Claude Opus 5.5 --- packages/plugin-rsc/e2e/fixture.ts | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 3f08ff2fa..e2c98c2e5 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -159,28 +159,12 @@ export function useFixture(options: { } } -// Space out writes to the same file by at least 100ms so Vite sees each one. -// Its watcher (chokidar) drops a `change` event within 50ms of the previous -// one for the same path, so a fast edit-then-reset could lose the reset. -const WRITE_INTERVAL_MS = 100 -const lastWriteTimes = new Map() - -function writeWatchedFile(filepath: string, content: string) { - const wait = - (lastWriteTimes.get(filepath) ?? 0) + WRITE_INTERVAL_MS - Date.now() - if (wait > 0) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait) - } - fs.writeFileSync(filepath, content) - lastWriteTimes.set(filepath, Date.now()) -} - export function useCreateEditor(cwd: string) { const originalFiles: Record = {} test.afterAll(async () => { for (const [filepath, content] of Object.entries(originalFiles)) { - writeWatchedFile(filepath, content) + fs.writeFileSync(filepath, content) } }) @@ -195,13 +179,13 @@ export function useCreateEditor(cwd: string) { const next = editFn(current) assert(next !== current, 'Edit function did not change the content') current = next - writeWatchedFile(filepath, next) + fs.writeFileSync(filepath, next) }, reset(): void { - writeWatchedFile(filepath, originalFiles[filepath]!) + fs.writeFileSync(filepath, originalFiles[filepath]!) }, resave(): void { - writeWatchedFile(filepath, current) + fs.writeFileSync(filepath, current) }, } }