From 033e1d4d255010353cdba86dc1e4bf89dd5bc3af Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 12 Mar 2026 01:53:14 +1100
Subject: [PATCH 1/7] fix: emit hashChangeStart/Complete and
beforeHistoryChange events
Next.js emits hashChangeStart/hashChangeComplete for hash-only
navigation and beforeHistoryChange before every history.pushState/
replaceState call. Our router emitted neither.
- Hash-only push/replace now emit hashChangeStart before and
hashChangeComplete after (instead of routeChange* events)
- Normal push/replace now emit beforeHistoryChange between
routeChangeStart and the pushState/replaceState call
- All events now pass { shallow } as the second argument per Next.js
- Popstate handler also emits beforeHistoryChange
- Updated in both useRouter() hook and Router singleton code paths
---
packages/vinext/src/shims/router.ts | 33 +++++++---
tests/e2e/pages-router/router-events.spec.ts | 62 +++++++++++++++++++
.../pages-basic/pages/router-events-test.tsx | 25 ++++++++
3 files changed, 110 insertions(+), 10 deletions(-)
diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts
index d3c9ff58..2377d371 100644
--- a/packages/vinext/src/shims/router.ts
+++ b/packages/vinext/src/shims/router.ts
@@ -505,22 +505,25 @@ export function useRouter(): NextRouter {
// Hash-only change — no page fetch needed
if (isHashOnlyChange(resolved)) {
+ routerEvents.emit("hashChangeStart", resolved, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
window.history.pushState({}, "", resolved.startsWith("#") ? resolved : full);
scrollToHash(hash);
setState(getPathnameAndQuery());
+ routerEvents.emit("hashChangeComplete", resolved, { shallow: options?.shallow ?? false });
window.dispatchEvent(new CustomEvent("vinext:navigate"));
return true;
}
saveScrollPosition();
- routerEvents.emit("routeChangeStart", resolved);
+ routerEvents.emit("routeChangeStart", resolved, { shallow: options?.shallow ?? false });
+ routerEvents.emit("beforeHistoryChange", resolved, { shallow: options?.shallow ?? false });
window.history.pushState({}, "", full);
if (!options?.shallow) {
await navigateClient(full);
}
setState(getPathnameAndQuery());
- routerEvents.emit("routeChangeComplete", resolved);
+ routerEvents.emit("routeChangeComplete", resolved, { shallow: options?.shallow ?? false });
// Scroll: handle hash target, else scroll to top unless scroll:false
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
@@ -553,21 +556,24 @@ export function useRouter(): NextRouter {
// Hash-only change — no page fetch needed
if (isHashOnlyChange(resolved)) {
+ routerEvents.emit("hashChangeStart", resolved, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
window.history.replaceState({}, "", resolved.startsWith("#") ? resolved : full);
scrollToHash(hash);
setState(getPathnameAndQuery());
+ routerEvents.emit("hashChangeComplete", resolved, { shallow: options?.shallow ?? false });
window.dispatchEvent(new CustomEvent("vinext:navigate"));
return true;
}
- routerEvents.emit("routeChangeStart", resolved);
+ routerEvents.emit("routeChangeStart", resolved, { shallow: options?.shallow ?? false });
+ routerEvents.emit("beforeHistoryChange", resolved, { shallow: options?.shallow ?? false });
window.history.replaceState({}, "", full);
if (!options?.shallow) {
await navigateClient(full);
}
setState(getPathnameAndQuery());
- routerEvents.emit("routeChangeComplete", resolved);
+ routerEvents.emit("routeChangeComplete", resolved, { shallow: options?.shallow ?? false });
// Scroll: handle hash target, else scroll to top unless scroll:false
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
@@ -641,9 +647,10 @@ if (typeof window !== "undefined") {
if (!shouldContinue) return;
}
- routerEvents.emit("routeChangeStart", appUrl);
+ routerEvents.emit("routeChangeStart", appUrl, { shallow: false });
+ routerEvents.emit("beforeHistoryChange", appUrl, { shallow: false });
void navigateClient(browserUrl).then(() => {
- routerEvents.emit("routeChangeComplete", appUrl);
+ routerEvents.emit("routeChangeComplete", appUrl, { shallow: false });
restoreScrollPosition(e.state);
window.dispatchEvent(new CustomEvent("vinext:navigate"));
});
@@ -693,20 +700,23 @@ const Router = {
// Hash-only change
if (isHashOnlyChange(resolved)) {
+ routerEvents.emit("hashChangeStart", resolved, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
window.history.pushState({}, "", resolved.startsWith("#") ? resolved : full);
scrollToHash(hash);
+ routerEvents.emit("hashChangeComplete", resolved, { shallow: options?.shallow ?? false });
window.dispatchEvent(new CustomEvent("vinext:navigate"));
return true;
}
saveScrollPosition();
- routerEvents.emit("routeChangeStart", resolved);
+ routerEvents.emit("routeChangeStart", resolved, { shallow: options?.shallow ?? false });
+ routerEvents.emit("beforeHistoryChange", resolved, { shallow: options?.shallow ?? false });
window.history.pushState({}, "", full);
if (!options?.shallow) {
await navigateClient(full);
}
- routerEvents.emit("routeChangeComplete", resolved);
+ routerEvents.emit("routeChangeComplete", resolved, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
if (hash) {
@@ -734,19 +744,22 @@ const Router = {
// Hash-only change
if (isHashOnlyChange(resolved)) {
+ routerEvents.emit("hashChangeStart", resolved, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
window.history.replaceState({}, "", resolved.startsWith("#") ? resolved : full);
scrollToHash(hash);
+ routerEvents.emit("hashChangeComplete", resolved, { shallow: options?.shallow ?? false });
window.dispatchEvent(new CustomEvent("vinext:navigate"));
return true;
}
- routerEvents.emit("routeChangeStart", resolved);
+ routerEvents.emit("routeChangeStart", resolved, { shallow: options?.shallow ?? false });
+ routerEvents.emit("beforeHistoryChange", resolved, { shallow: options?.shallow ?? false });
window.history.replaceState({}, "", full);
if (!options?.shallow) {
await navigateClient(full);
}
- routerEvents.emit("routeChangeComplete", resolved);
+ routerEvents.emit("routeChangeComplete", resolved, { shallow: options?.shallow ?? false });
const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : "";
if (hash) {
diff --git a/tests/e2e/pages-router/router-events.spec.ts b/tests/e2e/pages-router/router-events.spec.ts
index e09717bc..5f52563b 100644
--- a/tests/e2e/pages-router/router-events.spec.ts
+++ b/tests/e2e/pages-router/router-events.spec.ts
@@ -54,6 +54,68 @@ test.describe("router.events (Pages Router)", () => {
expect(events).toContain("complete:/about");
});
+ test("beforeHistoryChange fires between routeChangeStart and routeChangeComplete", async ({
+ page,
+ }) => {
+ await page.click('[data-testid="push-about"]');
+ await expect(page.locator("h1")).toHaveText("About");
+
+ const events: string[] = await page.evaluate((key) => {
+ const raw = sessionStorage.getItem(key);
+ return raw ? JSON.parse(raw) : [];
+ }, STORAGE_KEY);
+
+ const startIdx = events.findIndex((e) => e === "start:/about");
+ const beforeIdx = events.findIndex((e) => e === "beforeHistoryChange:/about");
+ const completeIdx = events.findIndex((e) => e === "complete:/about");
+ expect(startIdx).toBeGreaterThanOrEqual(0);
+ expect(beforeIdx).toBeGreaterThan(startIdx);
+ expect(completeIdx).toBeGreaterThan(beforeIdx);
+ });
+
+ test("hashChangeStart and hashChangeComplete fire on hash-only push", async ({ page }) => {
+ await page.click('[data-testid="push-hash"]');
+
+ const events: string[] = await page.evaluate((key) => {
+ const raw = sessionStorage.getItem(key);
+ return raw ? JSON.parse(raw) : [];
+ }, STORAGE_KEY);
+
+ expect(events.some((e) => e.startsWith("hashChangeStart:"))).toBe(true);
+ expect(events.some((e) => e.startsWith("hashChangeComplete:"))).toBe(true);
+ // Should NOT fire routeChange events for hash-only navigation
+ expect(events.some((e) => e.startsWith("start:"))).toBe(false);
+ expect(events.some((e) => e.startsWith("complete:"))).toBe(false);
+ });
+
+ test("hashChangeStart fires before hashChangeComplete on hash push", async ({ page }) => {
+ await page.click('[data-testid="push-hash"]');
+
+ const events: string[] = await page.evaluate((key) => {
+ const raw = sessionStorage.getItem(key);
+ return raw ? JSON.parse(raw) : [];
+ }, STORAGE_KEY);
+
+ const startIdx = events.findIndex((e) => e.startsWith("hashChangeStart:"));
+ const completeIdx = events.findIndex((e) => e.startsWith("hashChangeComplete:"));
+ expect(startIdx).toBeGreaterThanOrEqual(0);
+ expect(completeIdx).toBeGreaterThan(startIdx);
+ });
+
+ test("hashChangeStart and hashChangeComplete fire on hash-only replace", async ({ page }) => {
+ await page.click('[data-testid="replace-hash"]');
+
+ const events: string[] = await page.evaluate((key) => {
+ const raw = sessionStorage.getItem(key);
+ return raw ? JSON.parse(raw) : [];
+ }, STORAGE_KEY);
+
+ expect(events.some((e) => e.startsWith("hashChangeStart:"))).toBe(true);
+ expect(events.some((e) => e.startsWith("hashChangeComplete:"))).toBe(true);
+ expect(events.some((e) => e.startsWith("start:"))).toBe(false);
+ expect(events.some((e) => e.startsWith("complete:"))).toBe(false);
+ });
+
test("multiple navigations produce multiple event pairs", async ({ page }) => {
// Navigate to about
await page.click('[data-testid="push-about"]');
diff --git a/tests/fixtures/pages-basic/pages/router-events-test.tsx b/tests/fixtures/pages-basic/pages/router-events-test.tsx
index 2b2add68..42c8a16e 100644
--- a/tests/fixtures/pages-basic/pages/router-events-test.tsx
+++ b/tests/fixtures/pages-basic/pages/router-events-test.tsx
@@ -42,14 +42,33 @@ export default function RouterEventsTest() {
setEvents(getStoredEvents());
};
+ const onBeforeHistoryChange = (url: string) => {
+ storeEvent(`beforeHistoryChange:${url}`);
+ setEvents(getStoredEvents());
+ };
+ const onHashStart = (url: string) => {
+ storeEvent(`hashChangeStart:${url}`);
+ setEvents(getStoredEvents());
+ };
+ const onHashComplete = (url: string) => {
+ storeEvent(`hashChangeComplete:${url}`);
+ setEvents(getStoredEvents());
+ };
+
router.events.on("routeChangeStart", onStart);
router.events.on("routeChangeComplete", onComplete);
router.events.on("routeChangeError", onError);
+ router.events.on("beforeHistoryChange", onBeforeHistoryChange);
+ router.events.on("hashChangeStart", onHashStart);
+ router.events.on("hashChangeComplete", onHashComplete);
return () => {
router.events.off("routeChangeStart", onStart);
router.events.off("routeChangeComplete", onComplete);
router.events.off("routeChangeError", onError);
+ router.events.off("beforeHistoryChange", onBeforeHistoryChange);
+ router.events.off("hashChangeStart", onHashStart);
+ router.events.off("hashChangeComplete", onHashComplete);
};
}, [router]);
@@ -65,6 +84,12 @@ export default function RouterEventsTest() {
+
+