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() { + +