mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
When rapid edits overlap Server Components HMR refreshes in `next dev`, only the newest refresh can commit. The client already aborts a superseded refresh's fetch, which closes its response. We use that response-close to stop the server work the superseded refresh started, so the dev server no longer runs a render (and, under Cache Components, a validation) whose result is discarded. When the `serverComponentsHmrCancellation` flag is enabled and the request is an HMR refresh over a Node response, we derive an abort signal from `signalFromNodeResponse(ctx.res.originalResponse)` and thread it into the Flight render. This covers both dev RSC render paths: the Cache Components staged render in `generateDynamicFlightRenderResultWithStagesInDev` and the non-Cache-Components render in `generateDynamicFlightRenderResult`. The render is aborted through `renderToNodeFlightStream`, which calls `abort()` on the pipeable that `renderToPipeableStream` returns when the signal fires. React's Node Flight API has no `signal` option, unlike the Web `renderToReadableStream`, aborting the returned pipeable is the intended way to stop the render. This also removes the incorrect `signal` field from the `renderToPipeableStream` type declaration and derives `FlightRenderOptions` from `renderToReadableStream`'s options, so the render wrappers are no longer typed as `any`. Under Cache Components the superseded refresh additionally skips its detached validation, which only runs on the staged render, so it never validates a discarded tree, and any error thrown while its background renders tear down is swallowed. The aborted Flight renders surface as abort errors that `createReactServerErrorHandler` already ignores, so nothing reaches the error overlay or the CLI. The `hmr-rsc-cancellation` suite exercises both render paths, with Cache Components controlled by the `__NEXT_CACHE_COMPONENTS` test shard rather than the fixture config. A child component logs when React renders it, and the test asserts the superseded refresh never logs, since its render is aborted before reaching the child, while the committed refresh does. The detached-validation assertions run only when Cache Components is enabled.
36 lines
1.2 KiB
TypeScript
36 lines
1.2 KiB
TypeScript
import { connection } from 'next/server'
|
|
import { Suspense } from 'react'
|
|
|
|
// Editing any of these constants triggers a Server Components HMR refresh.
|
|
// `boundaryKey` is the Suspense boundary's key: changing it remounts the
|
|
// boundary, so the remounted boundary has no prior content and commits its
|
|
// fallback while `DynamicContent` streams (a partially committed tree).
|
|
const boundaryKey = 'initial'
|
|
const dynamicMarker = 'initial'
|
|
const dynamicDelayMs = 0
|
|
|
|
// Logs when React renders it. A superseded refresh's render is aborted while
|
|
// `DynamicContent` is still awaiting, so React never renders this child and no
|
|
// log arrives for that marker; only the refresh that commits logs.
|
|
function DynamicMarker({ marker }: { marker: string }) {
|
|
console.log(`[hmr-rsc-cancellation] rendered ${marker}`)
|
|
return <p id="dynamic">{marker}</p>
|
|
}
|
|
|
|
async function DynamicContent() {
|
|
await connection()
|
|
await new Promise((resolve) => setTimeout(resolve, dynamicDelayMs))
|
|
return <DynamicMarker marker={dynamicMarker} />
|
|
}
|
|
|
|
export default function Page() {
|
|
return (
|
|
<main>
|
|
<p id="shell">shell</p>
|
|
<Suspense key={boundaryKey} fallback={<p id="dynamic">loading</p>}>
|
|
<DynamicContent />
|
|
</Suspense>
|
|
</main>
|
|
)
|
|
}
|