Files
b1713c8517 [RSC HMR] Fix a flurry of refetches when a editing component imported from many routes (#96102)
Fixes a flurry of requests when doing server HMR.

To reproduce the issue, create a server component imported from multiple
routes:

- Route `/a` imports that component
- Route `/b` imports that component
- Route `/c` imports that component
- (...and so on...)

Then edit that server component.

Expected: one request in terminal per open tab.
Actual: a flurry of requests.

There's two separate causes for the flurry of requests:

- The "server component updated" messages were emitted once per
already-compiled route, so a single tab would receive a flurry of
messages one right after another, and process them all.
- Also, the `debounce` for these messages is broken (it always fires
instead of delaying) due to a wrong condition inside it. So if we tried
coalescing messages by key, we'd still see multiple requests per tab.
- We should probably fix `debounce` but that's more risky because I
don't know if either existing caller could rely on the bug. Luckily, for
the fix below this doesn't matter.

Anyway, to get the desired behavior (one refresh per page), we switch to
expressing server component changes as a final message that gets sent at
the end of the update using a dirty flag. Then we don't need to worry
about how they get deduped or how many of them gets received.

There are probably other ways to factor this but this one seemed fine to
me.

## Before

A flurry of requests.


https://github.com/user-attachments/assets/337183e9-6fe2-4633-858d-6c1db6779618

## After

One request per open tab.


https://github.com/user-attachments/assets/3543619e-7ab3-4b74-bbdf-2151e16e2c2b

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-24 03:59:27 +01:00

100 lines
3.1 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry, waitFor } from 'next-test-utils'
describe('hmr-refetch-coalescing', () => {
const { next, isTurbopack } = nextTestSetup({
files: __dirname,
})
if (!isTurbopack) {
it('is a Turbopack-only test', () => {})
return
}
function countHmrRefetches(counter: { count: number }) {
return {
beforePageLoad(page: any) {
page.on('request', (request: any) => {
if ('next-hmr-refresh' in request.headers()) {
counter.count++
}
})
},
}
}
it('refetches a page once per edit regardless of how many routes the edit affects', async () => {
// Build the other routes that import the shared component. Each built
// route registers a server-side change subscription for its endpoint.
for (const route of ['/b', '/c', '/d', '/e', '/f']) {
const res = await next.fetch(route)
expect(res.status).toBe(200)
}
const refetches = { count: 0 }
const browser = await next.browser('/a', countHmrRefetches(refetches))
expect(await browser.elementByCss('h1').text()).toBe('a: rev-0')
await next.patchFile('app/shared/banner.js', (content) =>
content.replace(/rev-\d+/, 'rev-1')
)
await retry(async () => {
expect(await browser.elementByCss('h1').text()).toBe('a: rev-1')
})
// Let any trailing (redundant) refetches land before counting.
await waitFor(2000)
// One edit must result in exactly one refetch of the page, no matter how
// many route endpoints the edited file is part of. Server-side change
// events arrive per affected endpoint; without coalescing, this client
// would refetch once per affected route (6 with this fixture).
expect(refetches.count).toBe(1)
// A second edit announces again.
await next.patchFile('app/shared/banner.js', (content) =>
content.replace(/rev-\d+/, 'rev-2')
)
await retry(async () => {
expect(await browser.elementByCss('h1').text()).toBe('a: rev-2')
})
await waitFor(2000)
expect(refetches.count).toBe(2)
await browser.close()
})
it('does not refetch for an edit with a compilation error, and refetches once for the fix', async () => {
const original = await next.readFile('app/shared/banner.js')
const refetches = { count: 0 }
const browser = await next.browser('/a', countHmrRefetches(refetches))
const initialText = await browser.elementByCss('h1').text()
await next.patchFile(
'app/shared/banner.js',
(content) => content + '\nconst broken = ;\n'
)
// The errored update must not be announced; wait long enough that a
// refetch would have happened.
await waitFor(2000)
expect(refetches.count).toBe(0)
await next.patchFile('app/shared/banner.js', () =>
original.replace(/rev-\d+/, 'rev-9')
)
await retry(async () => {
expect(await browser.elementByCss('h1').text()).not.toBe(initialText)
expect(await browser.elementByCss('h1').text()).toContain('rev-9')
})
await waitFor(2000)
expect(refetches.count).toBe(1)
await browser.close()
})
})