cloudflare / cloudflare/vinext
App Router: a superseded router.refresh() rejects its committed RSC payload into the error boundary (AbortError)
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
### Summary
In the App Router, a `router.refresh()` whose in-flight RSC (flight) stream is aborted by a superseding navigation rejects its **already-committed** payload promise inside React render, which propagates to the nearest error boundary and unmounts the page. In Chrome the boundary receives `DOMException: AbortError: BodyStreamBuffer was aborted` (other browsers word the abort differently). A full reload recovers, since the abort is transient.
The Pages/navigation path already handles this correctly by converting an aborted fetch into a swallowed `NavigationCancelledError`. The App Router refresh payload path has no equivalent guard on the promise handed to React.
### Expected
A superseded or aborted `router.refresh()` is a silent no-op, exactly like a superseded navigation. An abort is an intentional cancellation, not an application error, and must never reach a React error boundary.
### Actual
The refresh's committed RSC payload promise rejects during render with the underlying stream-abort error, and the nearest error boundary (often the root, via `global-error`) replaces the page.
### Root cause (verified against `1.0.0-beta.8`, unchanged since `1.0.0-beta.4`)
In `dist/server/app-browser-entry.js`, the App Router navigation loop reads the RSC flight response under an abort signal, and a superseding navigation aborts it. The refresh branch then:
1. Builds the payload from the still-streaming body and hands it, **pending**, to React:
- `rscPayload = decodeAppElementsPromise(createFromFetch(Promise.resolve(reactResponse)))` (~L1325)
- `renderNavigationPayload({ ..., payload: rscPayload, ... })` (~L1368) commits that pending promise into the React tree.
2. Guards only its **own** consumption of the promise:
- `try { const renderedElements = await rscPayload; ... } catch {}` (~L1385–L1416)
- the outer `catch (error) { if (!isCurrentNavigation(navId)) return; ... }` (~L1419) returns early once the navigation is superseded.
The empty `catch {}` and the `isCurrentNavigation` early-return keep vinext's *own* await from throwing, but React holds the *same* committed promise (step 1). When the aborted stream errors, that promise rejects while React is rendering it, and the rejection surfaces to the error boundary, outside vinext's `try/catch`.
By contrast, the navigation path in `dist/shims/router.js` explicitly maps an aborted fetch to a cancellation that is swallowed:
```js
// dist/shims/router.js (~L1291, ~L1395)
if (err instanceof DOMException && err.name === "AbortError")
throw new NavigationCancelledError(url);
```
The App Router refresh payload path has no matching treatment for the promise committed to React.
### Minimal reproduction
Any App Router route tree with a client component that refreshes on a timer or on window focus, plus ordinary navigation:
```tsx
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function AutoRefresh() {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), 3000);
window.addEventListener("focus", () => router.refresh());
return () => clearInterval(id);
}, [router]);
return null;
}
```
Mount it in the root layout, then navigate between two routes repeatedly (or return focus to the tab and immediately click a link) so a navigation supersedes an in-flight refresh. Intermittently the page unmounts to the error boundary with `AbortError: BodyStreamBuffer was aborted`.
The race is easiest to hit when a refresh is triggered by window `focus`/`visibilitychange` and the user clicks a link on return, which is a common real-world timing.
### Suggested direction
When a navigation/refresh is superseded (abort signal fired, or no longer the current navigation), the payload promise committed to React should resolve to a cancellation sentinel, or otherwise be prevented from rejecting into render, mirroring the navigation path's `NavigationCancelledError` swallow. Guarding only vinext's own `await` is insufficient because React holds an independent reference to the same pending promise.
### Environment
- `vinext` `1.0.0-beta.4` (root cause re-verified present in `1.0.0-beta.8`)
- `@vinext/cloudflare` on the Workers runtime
- React 19, Chrome
Contributor guide
Research direction
Start in dist/server/app-browser-entry.js at the App Router refresh branch and compare its payload handling with the aborted-fetch treatment in dist/shims/router.js. Reproduce the race using the provided App Router refresh-on-focus or timer example followed by navigation. Done means a superseded refresh is silent and never reaches the React error boundary.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nextjs, react, typescript
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100