Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/vinext/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ declare global {
*/
__VINEXT_CLEAR_NAV_CACHES__: (() => void) | undefined;

/**
* Commits an App Router hash-only navigation without fetching RSC data.
* Installed by the browser RSC entry so `next/navigation` and `next/link`
* can route app-owned hash-only history writes through the same vinext
* history metadata/index allocator as approved RSC commits.
*/
__VINEXT_RSC_COMMIT_HASH_NAVIGATION__:
| ((href: string, historyUpdateMode: "push" | "replace", scroll: boolean) => void)
| undefined;

/**
* A Promise that resolves when the current in-flight popstate RSC navigation
* finishes rendering.
Expand Down
57 changes: 57 additions & 0 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,61 @@ function commitHistoryTraversalIndex(index: number | null): void {
}
}

function commitHashOnlyNavigation(
href: string,
historyUpdateMode: Exclude<HistoryUpdateMode, undefined>,
scroll: boolean,
): void {
const navigationHistoryIndex = allocateNavigationHistoryTraversalIndex(historyUpdateMode);
const previousNextUrl = hasBrowserRouterState()
? getBrowserRouterState().previousNextUrl
: readHistoryStatePreviousNextUrl(window.history.state);
const historyState = createHistoryStateWithNavigationMetadata(
createHashOnlyNavigationBaseHistoryState(historyUpdateMode, scroll),
{
previousNextUrl,
traversalIndex: navigationHistoryIndex,
},
);

if (historyUpdateMode === "replace") {
replaceHistoryStateWithoutNotify(historyState, "", href);
} else {
pushHistoryStateWithoutNotify(historyState, "", href);
}
commitHistoryTraversalIndex(navigationHistoryIndex);
}

function createHashOnlyNavigationBaseHistoryState(
historyUpdateMode: Exclude<HistoryUpdateMode, undefined>,
scroll: boolean,
): unknown {
if (historyUpdateMode !== "replace") {
return null;
}
return scroll ? stripVinextScrollState(window.history.state) : window.history.state;
}

function stripVinextScrollState(state: unknown): unknown {
if (!state || typeof state !== "object") {
return state;
}

const nextState: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) {
if (key === "__vinext_scrollX" || key === "__vinext_scrollY") {
continue;
}
nextState[key] = value;
}

return Object.keys(nextState).length > 0 ? nextState : null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: when state is a non-null object whose only keys are __vinext_scrollX and __vinext_scrollY, this returns null. That means a replace-with-scroll of a history entry that had only vinext scroll keys (no __vinext_historyIndex, no __vinext_previousNextUrl) would produce a null base state, which createHistoryStateWithNavigationMetadata then populates from scratch — so functionally correct. Just flagging the implicit contract in case a future caller passes state through this function outside of createHashOnlyNavigationBaseHistoryState, where the metadata-writing step wouldn't necessarily follow.

}

function commitTraversalIndexFromHistoryState(historyState: unknown): void {
commitHistoryTraversalIndex(readHistoryStateTraversalIndex(historyState));
}

function getBrowserRouterState(): AppRouterState {
return browserNavigationController.getBrowserRouterState();
}
Expand Down Expand Up @@ -1113,6 +1168,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
// header comment: "the segment cache contains the actual RSC data which
// needs to be re-fetched."
window.__VINEXT_CLEAR_NAV_CACHES__ = clearClientNavigationCaches;
window.__VINEXT_RSC_COMMIT_HASH_NAVIGATION__ = commitHashOnlyNavigation;

window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc(
href: string,
Expand Down Expand Up @@ -1475,6 +1531,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
const href = window.location.href;
if (isSameAppRoutePopstateTarget(href)) {
notifyAppRouterTransitionStart(href, "traverse");
commitTraversalIndexFromHistoryState(event.state);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition. This is the same-route popstate fast path, so the traversal index must be committed here since navigateRsc is never called. The ordering — commit index then restore scroll — is correct: scroll restoration reads scroll keys from event.state, which is unaffected by the index commit (it only touches module-level variables).

restorePopstateScrollPosition(event.state);
return;
}
Expand Down
20 changes: 15 additions & 5 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,20 @@ function saveScrollPosition(): void {
);
}

function commitHashOnlyHistoryState(href: string, mode: "push" | "replace", scroll: boolean): void {
const commitAppRouterHashNavigation = window.__VINEXT_RSC_COMMIT_HASH_NAVIGATION__;
if (commitAppRouterHashNavigation) {
commitAppRouterHashNavigation(href, mode, scroll);
return;
}

if (mode === "replace") {
replaceHistoryStateWithoutNotify(null, "", href);
} else {
pushHistoryStateWithoutNotify(null, "", href);
}
}

/**
* Restore scroll position from a history state object (used on popstate).
*
Expand Down Expand Up @@ -1297,11 +1311,7 @@ export async function navigateClientSide(
// Hash-only change: update URL and scroll to target, skip RSC fetch
if (isHashOnlyChange(fullHref)) {
const hash = fullHref.includes("#") ? fullHref.slice(fullHref.indexOf("#")) : "";
if (mode === "replace") {
replaceHistoryStateWithoutNotify(null, "", fullHref);
} else {
pushHistoryStateWithoutNotify(null, "", fullHref);
}
commitHashOnlyHistoryState(fullHref, mode, scroll);
commitClientNavigationState();
if (scroll) {
scrollToHashTarget(hash);
Expand Down
77 changes: 77 additions & 0 deletions tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { waitForAppRouterHydration } from "../../helpers";
const BASE = "http://localhost:4174";

test.describe("Next.js compat: hash popstate scroll", () => {
async function readVinextHistoryIndex(page: Page) {
return page.evaluate(() => {
const state = window.history.state;
if (!state || typeof state !== "object") return null;

const value = (state as Record<string, unknown>).__vinext_historyIndex;
return typeof value === "number" ? value : null;
});
}

async function expectScrollY(page: Page, expected: number) {
await expect(async () => {
const scrollY = await page.evaluate(() => window.scrollY);
Expand Down Expand Up @@ -58,6 +68,73 @@ test.describe("Next.js compat: hash popstate scroll", () => {
await expect(page.locator("#content")).toBeInViewport();
});

test("hash-only Link entries carry vinext traversal metadata", async ({ page }) => {
await page.goto(`${BASE}/nextjs-compat/hash-popstate-scroll`);
await waitForAppRouterHydration(page);
await expect(page.locator("h1")).toHaveText("Hash Popstate Scroll");
expect(await readVinextHistoryIndex(page)).toBe(0);

await page.click("#hash-link");
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
expect(await readVinextHistoryIndex(page)).toBe(1);

await page.goBack();
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll`);
expect(await readVinextHistoryIndex(page)).toBe(0);

await page.goForward();
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
expect(await readVinextHistoryIndex(page)).toBe(1);
});

test("hash-only replace after back keeps the current traversal index", async ({ page }) => {
await page.goto(`${BASE}/nextjs-compat/hash-popstate-scroll`);
await waitForAppRouterHydration(page);

await page.click("#hash-link");
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
expect(await readVinextHistoryIndex(page)).toBe(1);

await page.goBack();
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll`);
expect(await readVinextHistoryIndex(page)).toBe(0);

await page.click("#replace-top");
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#top`);
expect(await readVinextHistoryIndex(page)).toBe(0);
});

test("hash-only replace after back restores scroll for the replaced hash target", async ({
page,
}) => {
await page.goto(`${BASE}/nextjs-compat/hash-popstate-scroll`);
await waitForAppRouterHydration(page);

await page.click("#hash-link");
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
expect(await readVinextHistoryIndex(page)).toBe(1);

await page.goBack();
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll`);
await expectScrollY(page, 0);
expect(await readVinextHistoryIndex(page)).toBe(0);

await page.click("#replace-content");
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
await expect(page.locator("#content")).toBeInViewport();
expect(await readVinextHistoryIndex(page)).toBe(0);

await page.goForward();
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
await expect(page.locator("#content")).toBeInViewport();
expect(await readVinextHistoryIndex(page)).toBe(1);

await page.goBack();
await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
await expect(page.locator("#content")).toBeInViewport();
expect(await readVinextHistoryIndex(page)).toBe(0);
});

// Next.js App Router handles popstate with ACTION_RESTORE and classifies
// same-path/search fragment changes as onlyHashChange in segment-cache
// navigation, avoiding a new RSC payload for hash-only traversal.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { useRouter } from "next/navigation";

export function HashActions() {
const router = useRouter();

return (
<>
<button id="replace-top" onClick={() => router.replace("#top")} type="button">
Replace top
</button>
<button id="replace-content" onClick={() => router.replace("#content")} type="button">
Replace content
</button>
</>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { HashActions } from "./hash-actions";

export default function HashPopstateScrollPage() {
return (
Expand All @@ -17,6 +18,7 @@ export default function HashPopstateScrollPage() {
<Link href="#top" id="top-link">
Go to top
</Link>
<HashActions />
</nav>
<div style={{ height: 1200 }} />
<section id="content" style={{ minHeight: 400 }}>
Expand Down
Loading