fix(app-router): keep refresh transitions pending#1269
Conversation
router.refresh() currently skips the pending browser router state even when it is dispatched from a React transition. That lets useTransition return to idle while the refresh RSC request is still in flight. The refresh path should follow the same App Router invariant as other programmatic navigations: publish a deferred router-state promise before the request and resolve it when the refreshed tree commits. Allow programmatic refresh navigations to create pending router state, and cover the behaviour with a browser regression that observes isPending during a delayed same-route refresh.
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Clean, well-scoped fix. The one-line change is correct: refresh is a programmatic navigation dispatched through React.startTransition (navigation.ts:1420) with programmaticTransition=true (navigation.ts:1418), so it should participate in the pending browser router state mechanism like push and replace already do.
The fixture and E2E test are well-structured — the 1s server delay gives reliable headroom for the 1s assertion timeout, the server stamp change confirms the refresh actually committed new data, and the test lives in the right spec file alongside the existing push-pending coverage.
Two minor observations below, neither blocking.
| export default async function RouterRefreshPendingPage() { | ||
| // Keep the refresh RSC request in flight long enough for the browser test to | ||
| // observe the transition's pending state before the refreshed tree commits. | ||
| await delay(1_000); |
There was a problem hiding this comment.
Nit: The 1s server delay and the 1s Playwright assertion timeout for observing "pending" are exactly matched. If the dev server is slow to start the RSC response (e.g. CI load spike), the delay might not provide enough headroom and the test could flake. The existing push-pending fixture also uses 1s, so this is consistent, but worth noting — if either ever flakes, bumping the delay to 1.5–2s (or increasing the assertion timeout to 2s) would add margin.
| <div> | ||
| <h1 id="router-refresh-pending-title">Router refresh pending</h1> | ||
| <RefreshPendingClient /> | ||
| <p id="refresh-server-stamp">server stamp: {Date.now()}</p> |
There was a problem hiding this comment.
Nit: Date.now() in the text is used both as a human-readable label ("server stamp: ...") and as the signal that refresh committed new data. This works because every render gets a new timestamp. Just noting: if this page ever gets static/ISR caching treatment in a future test run, the stamp would stop changing — the test would time out at the not.toHaveText assertion. Fine for now since the fixture is dynamic by default.
|
Review submitted. Here's the summary: Approved. The one-line change at The test fixture and Playwright assertion are well-structured and consistent with the existing push-pending coverage. Left two non-blocking nits about test timing margins and the |
Overview
useTransition()pending whilerouter.refresh()is fetching and committing the refreshed RSC tree.packages/vinext/src/server/app-browser-entry.ts,tests/e2e/app-router/nextjs-compat/router-push-pending.spec.tsrouter.refresh()instartTransitioncan show loading feedback until the refreshed route commits.Why
A programmatic App Router navigation that starts inside a React transition must publish a pending router state before the async RSC work begins, then resolve it when the committed tree is ready. Next.js does this for
ACTION_REFRESHthrough the same async action queue path as other router actions, so excluding refresh in vinext letsisPendingdrop to idle during the refresh request.router.refresh()is an async App Router action, not a fire-and-forget fetch from React's perspective.ACTION_REFRESHdispatch and action queue behavior.useTransitionstate.What changed
startTransition(() => router.refresh())isPendingcould return tofalsewhile the refresh RSC request was still in flight.isPendingbecomestrueduring the refresh and returns tofalseafter the refreshed tree commits.router.push()pending behaviorMaintainer review path
packages/vinext/src/server/app-browser-entry.ts: confirmnavigateRscnow treats refresh like other programmatic transitions when deciding whether to create pending browser router state.tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/*: inspect the minimal fixture that delays the server response outside Suspense so pending state is observable.tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts: review the new refresh regression alongside the existing push pending coverage.Validation
vp test run tests/app-browser-entry.test.tsvp run vinext#buildPLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts --project=app-routervp checkNote: the browser regression failed when run against the pre-rebuild package dist, which still contained the refresh exclusion. After rebuilding
vinextso the fixture served the patched runtime, it passed.Risk / compatibility
References
refresh()dispatchesACTION_REFRESHinsidestartTransition.useRouter().refresh()docs