Skip to content

[pull] canary from vercel:canary#1093

Merged
pull[bot] merged 6 commits into
code:canaryfrom
vercel:canary
Jun 2, 2026
Merged

[pull] canary from vercel:canary#1093
pull[bot] merged 6 commits into
code:canaryfrom
vercel:canary

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented Jun 2, 2026

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

icyJoseph and others added 6 commits June 2, 2026 10:07
The lint-language script is too verbose, it prints "no issues found" for
all files it touches.
### What?

Make `DiskFileSystem` write and symlink effects run on their own spawned
tasks, so multiple pending writes can execute in parallel rather than
serially on the caller's future.

### Why?

Effects emitted by `DiskFileSystem::write` and `write_link` were
previously applied inline within the caller's future. That meant the
work for each effect ran sequentially in the awaiting task — writes did
not overlap even though each one is largely I/O bound (path validation,
lock acquisition, file write, fsync).

### How?

- `WriteEffect` and `WriteLinkEffect` now derive `Clone` and own their
data so they can be moved into a spawned task. `full_path` becomes
`Arc<PathBuf>` to keep cloning cheap.
- `apply_inner` takes `self` by value instead of `&self`.
- `apply()` now calls `spawn(self.clone().apply_inner())` so the effect
body runs on a dedicated turbo-tasks task. Multiple effects awaited
concurrently will execute in parallel.

Closes NEXT-
Fixes #

<!-- NEXT_JS_LLM_PR -->
…94316)

`@typescript-eslint/switch-exhaustiveness-check` (enabled in
eslint.cli.config.mjs) already guarantees complete switch coverage on
enums and discriminated unions, so requiring an additional default case
on those switches in TypeScript just forced dead branches.

We turn on its `requireDefaultForNonUnion` option so that switches on
plain types such as `string` or `number` still require a default,
preserving the coverage that `default-case` previously gave us. We scope
the `default-case: off` override to exactly the files where the
type-checked rule runs — the same `files` and `ignores` as the
switch-exhaustiveness check — and cross-reference the two config blocks
so the globs stay in sync. That way `default-case` stays enabled for
JavaScript files, `.mts` files, and the directories the type-checked
config skips, so no switch is left unchecked.

The two `// eslint-disable-next-line default-case` directives in
`postcss-loader` and `head.tsx` are no longer the right escape hatch:
with `requireDefaultForNonUnion` the exhaustiveness check now wants
those switches handled, and a directive for that rule would be reported
as unused under the editor config where it does not run. We give each
switch an explicit `default` case instead, which both rules accept and
which makes the previously implicit fall-through behavior explicit.
When the browser's HTTP cache entry for a back-navigation target has
been evicted between forward visit and back-press — for example with a
long-lived tab, storage pressure, or a manual cache clear — the browser
re-fetches the document fresh from the server. Previously we relied on
`type === 'back_forward'` alone to decide that the document came from
cache, which meant we treated those re-fetches as cache restores too.
The persisted-chunk lookup would miss on the fresh response, and we'd
recover with an unnecessary `location.reload()`.

The same edge case is exposed by the Playwright/WebKit combination in
the upstack PR: bfcache isn't utilized there, so back-navigations always
come over the network, and the old single-signal check classified those
re-fetches as served-from-cache and triggered the same unnecessary
reload.

With this change we split the cache-restore detection into two phases.
At script-execution time we look at `deliveryType` (Chrome ≥109, Safari
≥17) and the navigation entry's `transferSize`/`encodedBodySize` to
decide cache-restore vs fresh-response when those fields are populated.
When the body bytes haven't been measured yet — the common case for
streaming Next.js dev RSC responses on every browser, and also WebKit
leaving both size fields at zero at exec time — we suspend the readable
until `pageshow` and re-check there. On a fresh re-fetch we route
through the live WebSocket-backed channel, which already has the debug
data for the new response, and skip the reload entirely.

The new `bfcache-regression` test exercises this edge case on Chromium
by clearing the browser cache via CDP between the forward and back
navigations, using a new `clearBrowserCache()` helper on the test
browser wrapper. The same exec-time code path is naturally hit by Safari
whenever its navigation entry's size fields are still zero at
script-execution time, but the harness can't force the eviction
deterministically there.
)

In development the debug channel that streams React's Server Component
debug information to the browser is buffered for the initial document
and persisted, so that it can be replayed when the browser later serves
the page from its HTTP cache — for example on back/forward navigation or
tab duplication — instead of forcing a full reload. This changes where
that buffer is persisted, moving it from `sessionStorage` to
`IndexedDB`.

The largest win comes from no longer having to serialize the data.
`sessionStorage` can only hold strings, so the binary debug chunks had
to be encoded into a string before being written and parsed back out
again on restore, which becomes expensive once the payload grows to
several megabytes. `IndexedDB` stores the chunks directly as the
`Uint8Array`s they already are, so there is no encode and parse round
trip on either side. Its asynchronous API helps a little on top of that,
since the write itself no longer has to happen synchronously.

The other improvement is that persistence no longer competes with
hydration. The initial document's debug stream closes while hydration is
still running, and the previous synchronous `sessionStorage` write
happened at exactly that moment, taking main thread time away from
hydration. The write is now deferred with `requestIdleCallback`, so it
only runs once the main thread is genuinely idle, which is after
hydration has drained, and it is skipped entirely if the page navigates
away before that happens, in which case a later restore simply falls
back to a reload. This is visible in the profiles below: with
`sessionStorage` there is still hydration work running after the
persistence task, whereas with the idle-scheduled `IndexedDB` write the
persistence only runs once hydration is done.

In a profile of a test page that deliberately transfers a large amount
of debug information, the persistence work on the main thread dropped
from more than 700 ms to roughly 25 to 45 ms. A real application with
far less debug data will see a smaller difference, so the test page
amplifies the effect, but the direction is the same.

The number of persisted entries stays bounded to 10, with the oldest
pruned on each write, and an end-to-end test covers the case where an
entry is pushed out by newer page loads so that navigating back to it
recovers through a page reload.

**Before with `sessionStorage`**:

<img width="973" height="627" alt="sessionStorage"
src="https://github.com/user-attachments/assets/b8ff8464-9ccf-4363-b1fe-78db0fd3e1eb"
/>

**After with idle-scheduled `IndexedDB`:**

<img width="973" height="627" alt="indexedDB"
src="https://github.com/user-attachments/assets/94a557a3-c82d-4771-bd81-3b4dfd906f25"
/>
The debug channel's Node streams implementation never closed on the
client. This leaked memory, because the resources held for each debug
channel — the buffered chunks and its entry in the client-side registry,
and the pending reader and stream pipeline on the server — are only
released once the channel closes. It also meant the buffered debug entry
was never persisted to `IndexedDB`, so the bfcache restore test timed
out waiting for it.

The server-side write target forwarded React's writes into the readable
side with `passthrough.push()` and signalled completion with
`passthrough.push(null)`. Because a `PassThrough` is a `Duplex`, pushing
`null` ends only its readable half and leaves the writable half open, so
`writableEnded` stays `false`. The readable is later consumed through
`Readable.toWeb()`, both in `connectReactDebugChannel` and inside
`teeStream`, and `Readable.toWeb()` never closes the resulting web
stream while the writable half is still open. The close therefore never
reached the client, nothing was cleaned up, and the test's
wait-for-persisted step never resolved.

Forwarding through `passthrough.write()` and `passthrough.end()` instead
ends both halves, so the close propagates through the conversion and the
channel closes on the client as expected.

This also re-enables the previously skipped node streams case of the
bfcache regression test, which now passes alongside the web streams
case.
@pull pull Bot locked and limited conversation to collaborators Jun 2, 2026
@pull pull Bot added the ⤵️ pull label Jun 2, 2026
@pull pull Bot merged commit beaa1b3 into code:canary Jun 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants