-
Notifications
You must be signed in to change notification settings - Fork 222
fix: emit hashChangeStart/Complete and beforeHistoryChange router events #468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
033e1d4
5336ce3
0276b52
3232c85
e318312
d05e0ac
acce8f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<void> { | |||||||||
| 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, { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: 6 near-identical hash event blocks. The hash-change event emission pattern (compute Not asking you to refactor in this PR — the duplication is pre-existing and the patterns are clear. But a future follow-up could extract the hash-change logic into a shared helper to reduce the surface area for bugs when the event contract changes again. |
||||||||||
| 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 = | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good design. The |
||||||||||
| 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. | ||||||||||
|
Comment on lines
667
to
+670
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good design choice using pathname+search (excluding hash) for the comparison. This correctly identifies hash-only navigation: if only the hash changed, Edge case that works correctly: if the user lands directly on |
||||||||||
| const isHashOnly = browserUrl === _lastPathnameAndSearch; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirming I've thought through the edge cases for If the user lands on One subtlety: if the user lands on |
||||||||||
|
|
||||||||||
| // 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) { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add
Suggested change
|
||||||||||
| // Hash-only back/forward — no page fetch needed | ||||||||||
| const hashUrl = appUrl + window.location.hash; | ||||||||||
| routerEvents.emit("hashChangeStart", hashUrl, { shallow: false }); | ||||||||||
|
Comment on lines
+688
to
+691
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The hash-only popstate branch is a good addition — avoids the unnecessary Note for a follow-up: this branch doesn't trigger a React state update, so components reading
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is actually already handled — line 698 dispatches |
||||||||||
| 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(() => { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: For popstate (browser back/forward), the browser has already changed In Next.js, the popstate handler calls This is arguably acceptable as a simplification since vinext doesn't store history state metadata. But if you keep it, a comment explaining the difference would help future readers understand this isn't truly "before" the history change. |
||||||||||
| 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, { | ||||||||||
|
Comment on lines
+757
to
+765
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-existing issue surfaced by this PR: The This means
|
||||||||||
| 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) { | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good negative assertions. Consider also asserting that
Suggested change
|
||||||||||||
| expect(events.some((e) => e.startsWith("complete:"))).toBe(false); | ||||||||||||
| expect(events.some((e) => e.startsWith("beforeHistoryChange:"))).toBe(false); | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — the |
||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| 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); | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here — add a
Suggested change
|
||||||||||||
| 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); | ||||||||||||
| }); | ||||||||||||
|
Comment on lines
+126
to
+150
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Excellent test — this exercises the Consider adding a |
||||||||||||
|
|
||||||||||||
| 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"]'); | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: potential flakiness in forward test.
Not a required change — just a note for debugging if it ever flakes.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor robustness note: after |
||||||||||||
| 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"]'); | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function is clean and correct. Handles both cases well: fragment-only URLs (
#foo) get the current pathname prepended, and full-path URLs (/page#hash) pass through unchanged sinceisHashOnlyChangecan return true for both forms.