diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index d3c9ff58..c3da2baf 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -77,10 +77,6 @@ interface TransitionOptions { locale?: string; } -// Route event handler types (used by consumers via router.events) -type _RouteChangeHandler = (url: string) => void; -type _RouteErrorHandler = (err: Error, url: string) => void; - interface RouterEvents { on(event: string, handler: (...args: unknown[]) => void): void; off(event: string, handler: (...args: unknown[]) => void): void; @@ -156,6 +152,20 @@ export function isExternalUrl(url: string): boolean { return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//"); } +/** Resolve a hash URL to a basePath-stripped app URL for event payloads */ +function resolveHashUrl(url: string): string { + if (typeof window === "undefined") return url; + if (url.startsWith("#")) + return stripBasePath(window.location.pathname, __basePath) + window.location.search + url; + // Full-path hash URL — strip basePath for consistency with other events + try { + const parsed = new URL(url, window.location.href); + return stripBasePath(parsed.pathname, __basePath) + parsed.search + parsed.hash; + } catch { + return url; + } +} + /** Check if a href is only a hash change relative to the current URL */ export function isHashOnlyChange(href: string): boolean { if (href.startsWith("#")) return true; @@ -420,7 +430,7 @@ async function navigateClient(url: string): Promise { root.render(element); } catch (err) { console.error("[vinext] Client navigation failed:", err); - routerEvents.emit("routeChangeError", err, url); + routerEvents.emit("routeChangeError", err, url, { shallow: false }); window.location.href = url; } finally { _navInProgress = false; @@ -505,22 +515,32 @@ export function useRouter(): NextRouter { // Hash-only change — no page fetch needed if (isHashOnlyChange(resolved)) { + const eventUrl = resolveHashUrl(resolved); + routerEvents.emit("hashChangeStart", eventUrl, { + shallow: options?.shallow ?? false, + }); const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.pushState({}, "", resolved.startsWith("#") ? resolved : full); + _lastPathnameAndSearch = window.location.pathname + window.location.search; scrollToHash(hash); setState(getPathnameAndQuery()); + routerEvents.emit("hashChangeComplete", eventUrl, { + 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); + _lastPathnameAndSearch = window.location.pathname + window.location.search; 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 +573,31 @@ export function useRouter(): NextRouter { // Hash-only change — no page fetch needed if (isHashOnlyChange(resolved)) { + const eventUrl = resolveHashUrl(resolved); + routerEvents.emit("hashChangeStart", eventUrl, { + shallow: options?.shallow ?? false, + }); const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.replaceState({}, "", resolved.startsWith("#") ? resolved : full); + _lastPathnameAndSearch = window.location.pathname + window.location.search; scrollToHash(hash); setState(getPathnameAndQuery()); + routerEvents.emit("hashChangeComplete", eventUrl, { + 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); + _lastPathnameAndSearch = window.location.pathname + window.location.search; 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("#")) : ""; @@ -623,6 +653,12 @@ export function useRouter(): NextRouter { // If it returns false, the navigation is cancelled. let _beforePopStateCb: BeforePopStateCallback | undefined; +// Track pathname+search for detecting hash-only back/forward in the popstate +// handler. Updated after every pushState/replaceState so that popstate can +// compare the previous value with the (already-changed) window.location. +let _lastPathnameAndSearch = + typeof window !== "undefined" ? window.location.pathname + window.location.search : ""; + // Module-level popstate listener: handles browser back/forward by re-rendering // the React root with the page at the new URL. This runs regardless of whether // any component calls useRouter(). @@ -631,6 +667,9 @@ if (typeof window !== "undefined") { const browserUrl = window.location.pathname + window.location.search; const appUrl = stripBasePath(window.location.pathname, __basePath) + window.location.search; + // Detect hash-only back/forward: pathname+search unchanged, only hash differs. + const isHashOnly = browserUrl === _lastPathnameAndSearch; + // Check beforePopState callback if (_beforePopStateCb !== undefined) { const shouldContinue = (_beforePopStateCb as BeforePopStateCallback)({ @@ -641,9 +680,31 @@ if (typeof window !== "undefined") { if (!shouldContinue) return; } - routerEvents.emit("routeChangeStart", appUrl); + // Update tracker only after beforePopState confirms navigation proceeds. + // If beforePopState cancels, the tracker must retain the previous value + // so the next popstate compares against the correct baseline. + _lastPathnameAndSearch = browserUrl; + + if (isHashOnly) { + // Hash-only back/forward — no page fetch needed + const hashUrl = appUrl + window.location.hash; + routerEvents.emit("hashChangeStart", hashUrl, { shallow: false }); + scrollToHash(window.location.hash); + routerEvents.emit("hashChangeComplete", hashUrl, { shallow: false }); + window.dispatchEvent(new CustomEvent("vinext:navigate")); + return; + } + + const fullAppUrl = appUrl + window.location.hash; + routerEvents.emit("routeChangeStart", fullAppUrl, { shallow: false }); + // Note: The browser has already updated window.location by the time popstate + // fires, so this is not truly "before" the URL change. In Next.js the popstate + // handler calls replaceState to store history metadata — beforeHistoryChange + // precedes that call, not the URL change itself. We emit it here for API + // compatibility. + routerEvents.emit("beforeHistoryChange", fullAppUrl, { shallow: false }); void navigateClient(browserUrl).then(() => { - routerEvents.emit("routeChangeComplete", appUrl); + routerEvents.emit("routeChangeComplete", fullAppUrl, { shallow: false }); restoreScrollPosition(e.state); window.dispatchEvent(new CustomEvent("vinext:navigate")); }); @@ -693,20 +754,30 @@ const Router = { // Hash-only change if (isHashOnlyChange(resolved)) { + const eventUrl = resolveHashUrl(resolved); + routerEvents.emit("hashChangeStart", eventUrl, { + shallow: options?.shallow ?? false, + }); const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.pushState({}, "", resolved.startsWith("#") ? resolved : full); + _lastPathnameAndSearch = window.location.pathname + window.location.search; scrollToHash(hash); + routerEvents.emit("hashChangeComplete", eventUrl, { + 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); + _lastPathnameAndSearch = window.location.pathname + window.location.search; 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 +805,29 @@ const Router = { // Hash-only change if (isHashOnlyChange(resolved)) { + const eventUrl = resolveHashUrl(resolved); + routerEvents.emit("hashChangeStart", eventUrl, { + shallow: options?.shallow ?? false, + }); const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.replaceState({}, "", resolved.startsWith("#") ? resolved : full); + _lastPathnameAndSearch = window.location.pathname + window.location.search; scrollToHash(hash); + routerEvents.emit("hashChangeComplete", eventUrl, { + 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); + _lastPathnameAndSearch = window.location.pathname + window.location.search; 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..7ff64728 100644 --- a/tests/e2e/pages-router/router-events.spec.ts +++ b/tests/e2e/pages-router/router-events.spec.ts @@ -54,6 +54,129 @@ 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 with full URL", 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); + + // Event URL should include pathname, not just the fragment + expect(events).toContain("hashChangeStart:/router-events-test#section-1"); + expect(events).toContain("hashChangeComplete:/router-events-test#section-1"); + // Should NOT fire routeChange or beforeHistoryChange events for hash-only navigation + expect(events.some((e) => e.startsWith("start:"))).toBe(false); + expect(events.some((e) => e.startsWith("complete:"))).toBe(false); + expect(events.some((e) => e.startsWith("beforeHistoryChange:"))).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 with full URL", 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).toContain("hashChangeStart:/router-events-test#section-2"); + expect(events).toContain("hashChangeComplete:/router-events-test#section-2"); + expect(events.some((e) => e.startsWith("start:"))).toBe(false); + expect(events.some((e) => e.startsWith("complete:"))).toBe(false); + expect(events.some((e) => e.startsWith("beforeHistoryChange:"))).toBe(false); + }); + + test("hash-only back/forward emits hashChange events, not routeChange", async ({ page }) => { + // Push a hash change, then go back — popstate should detect hash-only navigation + await page.click('[data-testid="push-hash"]'); + await page.click('[data-testid="clear-events"]'); + await page.goBack(); + + // Wait for popstate handler to record events + await page.waitForFunction((key) => { + const raw = sessionStorage.getItem(key); + return raw ? JSON.parse(raw).length > 0 : false; + }, STORAGE_KEY); + + const events: string[] = await page.evaluate((key) => { + const raw = sessionStorage.getItem(key); + return raw ? JSON.parse(raw) : []; + }, STORAGE_KEY); + + // Should emit hashChange events for hash-only back navigation + expect(events.some((e) => e.startsWith("hashChangeStart:"))).toBe(true); + expect(events.some((e) => e.startsWith("hashChangeComplete:"))).toBe(true); + // Should NOT emit routeChange or beforeHistoryChange events + expect(events.some((e) => e.startsWith("start:"))).toBe(false); + expect(events.some((e) => e.startsWith("complete:"))).toBe(false); + expect(events.some((e) => e.startsWith("beforeHistoryChange:"))).toBe(false); + }); + + test("hash-only forward emits hashChange events, not routeChange", async ({ page }) => { + // Push a hash, go back, wait for popstate to settle, clear events, then go forward + await page.click('[data-testid="push-hash"]'); + await page.goBack(); + await page.waitForFunction((key) => { + const raw = sessionStorage.getItem(key); + return raw ? JSON.parse(raw).length > 0 : false; + }, STORAGE_KEY); + await page.click('[data-testid="clear-events"]'); + await page.goForward(); + + await page.waitForFunction((key) => { + const raw = sessionStorage.getItem(key); + return raw ? JSON.parse(raw).length > 0 : false; + }, STORAGE_KEY); + + 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); + expect(events.some((e) => e.startsWith("beforeHistoryChange:"))).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() { + +