Abort superseded Server Components HMR requests on the client (#95463)
In `next dev`, rapid edits can start overlapping Server Components HMR
refreshes, but only the newest can commit. When a newer refresh
supersedes an older one, we abort the older request on the client so it
stops transferring and decoding RSC data, and we keep the browser from
reissuing the superseding request as a duplicate. Everything here is
gated on the `serverComponentsHmrCancellation` flag.
On the client, the router's `hmrRefresh` method tracks the newest
refresh generation in an `AbortController` and aborts the previous one
before scheduling the new one, threading the abort signal through the
HMR action down to the `fetch` call. An aborted request becomes a
`NavigationTaskExitStatus.Canceled` rather than a failure, so
`finishNavigationTask` leaves its cache nodes for the newer navigation
to fulfill and does not retry or fall back to an MPA navigation.
`hmrRefreshReducer` also bails out when its generation was aborted
before it ran, which happens when an HMR action waits behind a Server
Action in the router queue.
The subtle part is how an aborted request's partially received response
is handled. A superseded refresh can already have committed part of its
tree with a Suspense boundary still streaming. Rather than letting the
aborted fetch reject the still-pending rows, which a committed boundary
would throw on, we decode the response through a wrapper stream that we
close, so React marks the unresolved rows as halted: they suspend during
render instead of rejecting, and the superseded boundary keeps its
fallback until the newer response commits. Closing the stream also
avoids the unclosed-stream memory leak that #89610 fixed for prefetch
streams.
Aborting the superseded fetch exposed a Chromium-only side effect. In
development, App Router responses are served with `Cache-Control:
no-cache, must-revalidate` so the browser can restore them on back and
forward navigation, which keeps each response stored and keyed by URL.
Successive refreshes of the same page share one cache entry, and
aborting the superseded refresh mid-write leaves that entry
half-written; Chromium discards it and reissues another superseding
refresh on a second connection. The dev server then renders it twice and
emits its debug channel twice under the same request id, producing
`Cannot write to a CLOSED writable stream` errors on the client. Firefox
and Safari do not reissue. To prevent this we serve HMR refresh
responses, identified by the `next-hmr-refresh` request header, with
`Cache-Control: no-store`, so no shared entry exists for an aborted
refresh to leave half-written. All other dev responses keep `no-cache`,
so their restore behavior is unchanged.
A development test suite covers a superseded request being aborted while
the newest commits without a hard reload, a partially committed render
whose Suspense boundary is left streaming not surfacing an error when
superseded, and disabling the flag preserving the previous behavior. The
supersession tests also assert no browser console errors, which guards
against the duplicate debug channel regression.
Cancelling the superseded request's server-side render and validation,
so the dev server also stops the discarded work, is left to a follow-up.