next.js
5f577743 - Abort superseded Server Components HMR requests on the client (#95463)

Commit
1 day ago
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.
Author
Parents
Loading