Skip to content

App Router: removing the hash (/page#a → /page) triggers a full RSC fetch instead of same-document scroll #1985

Description

@Divkix

Problem

Navigating from /page#section to /page (same path and query, hash removed) issues an unnecessary RSC fetch and full route re-render in vinext, whereas Next.js performs a same-document URL update + scroll-to-top with no fetch. Users lose transient client state and see a content flash/network round-trip on what should be an instant in-page navigation. Affects router.push('/page'), Link to a hash-less URL from a hashed one, and 'scroll to top by clearing the hash' patterns.

Evidence

  • packages/vinext/src/server/navigation-planner.ts:579 — classifyEarlyNavigationIntent only returns 'sameDocumentScroll' when samePathname && sameSearch && next.hash !== ""; a target with an empty hash (removing an existing #fragment) fails this and falls through to the crossDocumentFlight branch at line 597.
  • packages/vinext/src/server/navigation-planner.ts:597 — When pathname+search are identical but the hash was removed, the function returns kind:'flightNavigation' (crossDocumentFlight), causing a real RSC fetch and full tree re-render.
  • packages/vinext/src/shims/navigation.ts:1807 — navigateClientSide acts on the planner decision: only 'sameDocumentScroll' short-circuits without a fetch; any flightNavigation result proceeds to appNavigate() (RSC request) at line 1854. No earlier hash-removal special case exists.
  • .nextjs-ref/packages/next/src/client/components/segment-cache/navigation.ts:598 — Next.js computes onlyHashChange = url.pathname === oldUrl.pathname && url.search === oldUrl.search && url.hash !== oldUrl.hash, i.e. ANY hash delta (including removal) is treated as a hash-only, fetch-free navigation.

Next.js behavior (Next.js handles this correctly — vinext-only bug)

I traced the exact /page#section → /page (hash removed) scenario through the current Next.js (v16.3.0-canary.7) client nav path. router.push dispatches ACTION_NAVIGATE → navigateReducer → navigateUsingSegmentCache → navigateImpl. createCacheKey (cache-key.ts:25-29) excludes the hash, so the target shares the current route's cache key and yields a Fulfilled hit, routing through navigateUsingPrefetchedRouteTree → navigateToKnownRoute → startPPRNavigation. Because the pathname and search are identical, every segment matches in updateCacheNodeOnNavigation; with FreshnessPolicy.Default and an existing cache node (and isSamePageNavigation=false since hrefs differ only by hash, so the leaf-refetch special case does not apply), needsDynamicRequest stays false, createDynamicRequestTree returns null, and spawnDynamicRequests early-returns at ppr-navigations.ts:1288-1292 issuing NO RSC fetch. Meanwhile onlyHashChange (navigation.ts:598-603) is true for hash removal because it tests url.hash !== oldUrl.hash, driving a same-document scroll. The e2e test at navigation.test.ts:192-199 explicitly asserts hash-only transitions make no payload RSC request. This is the inverse of vinext, whose classifyEarlyNavigationIntent (navigation-planner.ts:579) requires next.hash !== "" and falls through to crossDocumentFlight (line 597), forcing a real fetch on hash removal — a vinext-only defect.

Citations: .nextjs-ref/packages/next/src/client/components/segment-cache/navigation.ts:598-603 (onlyHashChange = pathname equal && search equal && url.hash !== oldUrl.hash — ANY hash delta, including removal); .nextjs-ref/packages/next/src/client/components/segment-cache/cache-key.ts:25-29 (cache key = pathname+search+nextUrl, hash excluded → guaranteed Fulfilled cache hit for the current route); .nextjs-ref/packages/next/src/client/components/segment-cache/navigation.ts:250 (isSamePageNavigation = url.href === currentUrl.href, false when only hash removed); .nextjs-ref/packages/next/src/client/component

Suggested fix

Broaden the same-document branch to fire whenever samePathname && sameSearch && current.hash !== next.hash (compare against current.hash, which is already available as current URL). When next.hash is empty, the EarlyNavigationIntentDecisionV0 'hash' invariant ('always non-empty') must be relaxed and the downstream consumer (navigateClientSide line 1807-1813 / scrollToHashTarget) must treat an empty hash as scroll-to-top instead of a hash target.

Test plan

Nav test: /page#a → /page issues no RSC fetch, preserves client state, scrolls to top.

Related / notes

Next.js treats ANY hash delta incl. removal as fetch-free (segment-cache/navigation.ts:598 onlyHashChange). Distinct from PR #1952 (Pages Router basePath hash-only links). BLOCKED: wait for PR #1962 to merge — it rewrites navigation-planner.ts/app-browser-entry.ts/navigation.ts.


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