cloudflare / cloudflare/vinext

App Router: a superseded router.refresh() rejects its committed RSC payload into the error boundary (AbortError)

Open
#3,130 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.