mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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]>
100 lines
3.1 KiB
TypeScript
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()
|
|
})
|
|
})
|