Skip to content

Dev: recovery boundary leaves navigate() unsettled and permanently leaks activeNavigationCount #1986

Description

@Divkix

Problem

Dev-only: when a soft navigation's target throws during render (caught by DevRecoveryBoundary), the navigate() promise hangs until the next successful HMR render resolves it, and the discarded-server-action refresh scheduler's activeNavigationCount stays incremented — so queued revalidation refreshes (which require count===0 to flush) never fire for the rest of the session. Also retains a pendingNavigationCommits entry. Production is unaffected (no boundary; the tree unmounts and cleanup runs).

Evidence

  • packages/vinext/src/server/app-browser-navigation-controller.ts:741 — renderNavigationPayload returns committed.then(...); the committed promise only resolves via resolveCommittedNavigations(renderId), which is called exclusively inside NavigationCommitSignal's useLayoutEffect/cleanup (lines 498, 505).
  • packages/vinext/src/server/app-browser-entry.ts:889 — handleDevRecoveryBoundaryCatch calls only browserNavigationController.drainPrePaintEffects(resetKey); it never calls resolveCommittedNavigations, so the failing renderId's committed promise is never resolved by the catch path.
  • packages/vinext/src/shims/error-boundary.tsx:588 — DevRecoveryBoundary.componentDidCatch renders null instead of the subtree, so the NavigationCommitSignal for the failing renderId never mounts — neither its layout effect (drains/resolves) nor its unmount cleanup (resolveCommittedNavigations) ever runs for that renderId.
  • packages/vinext/src/server/app-browser-entry.ts:2097 — Because the awaited renderNavigationPayload (line 2028) never settles, navigateRsc's finally never runs: finalizeNavigation and discardedServerActionRefreshScheduler.markNavigationSettled() (line 2098) are skipped, leaving activeNavigationCount permanently > 0 after a dev render error during navigation.

Next.js behavior (vinext-internal mechanism — no Next.js counterpart (still a real vinext bug))

The vinext bug stems from coupling navigation-promise settlement to a successful React render commit: renderNavigationPayload returns committed.then(...), and committed only resolves via NavigationCommitSignal's useLayoutEffect/cleanup, which lives inside the navigated subtree; when DevRecoveryBoundary renders null on a caught render error the signal never mounts, so committed hangs and navigateRsc's finally (markNavigationSettled, decrementing activeNavigationCount) never runs. Next.js has no equivalent mechanism: its App Router navigation is reducer-based and lives outside React. The navigate promise (action.resolve/reject) is settled inside runAction/handleResult from the reducer's returned state/promise, with runRemainingActions always invoked on both success and error paths, fully decoupled from whether the target page renders successfully. The navigate reducer and segment-cache navigate() return AppRouterState (or a Promise of it) — pure state, never blocked on a render-commit effect — so a page that throws into an error boundary cannot strand the navigation or leak a count. The discarded-server-action refresh uses a simple needsRefresh boolean flushed by runRemainingActions, not an activeNavigationCount requiring count===0 from a render-phase effect. The committed/NavigationCommitSignal/DevRecoveryBoundary commit mechanism is vinext-internal with no Next.js counterpart.

Citations: .nextjs-ref/packages/next/src/client/components/app-router-instance.ts:111-143 (handleResult/runAction settle action.resolve/reject from the reducer result, always calling runRemainingActions in both success and error branches); app-router-instance.ts:146-216,278-314 (dispatchAction/dispatchNavigateAction settle via reducer, not a render-commit signal); app-router-instance.ts:54,88-92,121 (discarded-action revalidation uses a needsRefresh boolean flushed by runRemainingActions, not an activeNavigationCount gated on count===0); .nextjs-ref/packages/next/src/client/components/router-reducer/redu

Suggested fix

In handleDevRecoveryBoundaryCatch (or drainPrePaintEffects), also resolve committed navigations up to the failed renderId (e.g. expose/call resolveCommittedNavigations(resetKey) after draining) so the renderNavigationPayload promise settles and navigateRsc's finally runs even when NavigationCommitSignal never mounts.

Test plan

Dev test: nav target throws → navigate() settles; queued refresh flushes after recovery.

Related / notes

Dev-only; vinext-internal mechanism (no Next.js counterpart — Next.js has no DevRecoveryBoundary). After one render error during navigation, queued discarded-action refreshes never flush for the session. BLOCKED on PR #1962 (same files).


Found via a deep source audit of main @ fd10233 (2026-06-12). Behavior parity-checked against Next.js v16.3.0-canary.7 source; citations above reference packages/next/src/... in the Next.js repo. Screened against all open issues/PRs as of 2026-06-12 to avoid duplicating tracked work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions