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.
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(...); thecommittedpromise 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'scommittedpromise 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 referencepackages/next/src/...in the Next.js repo. Screened against all open issues/PRs as of 2026-06-12 to avoid duplicating tracked work.