Skip to content

Commit f0fe1cd

Browse files
committed
fix(router): support anchor fragments in href
1 parent 95c48eb commit f0fe1cd

8 files changed

Lines changed: 508 additions & 21 deletions

File tree

core/src/components/router/router.tsx

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { NavigationHookResult } from '../route/route-interface';
99

1010
import { ROUTER_INTENT_BACK, ROUTER_INTENT_FORWARD, ROUTER_INTENT_NONE } from './utils/constants';
1111
import { printRedirects, printRoutes } from './utils/debug';
12-
import { readNavState, waitUntilNavNode, writeNavState } from './utils/dom';
12+
import { readNavState, scrollToFragment, waitUntilNavNode, writeNavState } from './utils/dom';
1313
import type { RouteChain, RouterDirection, RouterEventDetail } from './utils/interface';
1414
import { findChainForIDs, findChainForSegments, findRouteRedirect } from './utils/matching';
1515
import { readRedirects, readRoutes } from './utils/parser';
@@ -24,6 +24,7 @@ export class Router implements ComponentInterface {
2424
private state = 0;
2525
private lastState = 0;
2626
private waitPromise?: Promise<void>;
27+
private fragmentScrollToken = 0;
2728

2829
@Element() el!: HTMLElement;
2930

@@ -67,11 +68,18 @@ export class Router implements ComponentInterface {
6768
if (typeof canProceed === 'object') {
6869
const { redirect } = canProceed;
6970
const path = parsePath(redirect);
70-
this.setSegments(path.segments, ROUTER_INTENT_NONE, path.queryString);
71-
await this.writeNavStateRoot(path.segments, ROUTER_INTENT_NONE);
71+
this.setSegments(path.segments, ROUTER_INTENT_NONE, path.queryString, path.fragment);
72+
const result = await this.writeNavStateRoot(path.segments, ROUTER_INTENT_NONE);
73+
if (result) {
74+
this.maybeScrollToFragment();
75+
}
7276
}
73-
} else {
74-
await this.onRoutesChanged();
77+
return;
78+
}
79+
80+
const result = await this.onRoutesChanged();
81+
if (result) {
82+
this.maybeScrollToFragment();
7583
}
7684
}
7785

@@ -93,7 +101,12 @@ export class Router implements ComponentInterface {
93101
return false;
94102
}
95103
}
96-
return this.writeNavStateRoot(segments, direction);
104+
const result = await this.writeNavStateRoot(segments, direction);
105+
if (result) {
106+
this.maybeScrollToFragment();
107+
}
108+
109+
return result;
97110
}
98111

99112
@Listen('ionBackButton', { target: 'document' })
@@ -132,7 +145,7 @@ export class Router implements ComponentInterface {
132145
const currentPath = this.previousPath ?? '/';
133146
// Convert currentPath to an URL by pre-pending a protocol and a host to resolve the relative path.
134147
const url = new URL(path, `https://host/${currentPath}`);
135-
path = url.pathname + url.search;
148+
path = url.pathname + url.search + url.hash;
136149
}
137150

138151
let parsedPath = parsePath(path);
@@ -146,8 +159,13 @@ export class Router implements ComponentInterface {
146159
}
147160
}
148161

149-
this.setSegments(parsedPath.segments, direction, parsedPath.queryString);
150-
return this.writeNavStateRoot(parsedPath.segments, direction, animation);
162+
this.setSegments(parsedPath.segments, direction, parsedPath.queryString, parsedPath.fragment);
163+
const result = await this.writeNavStateRoot(parsedPath.segments, direction, animation);
164+
if (result) {
165+
this.maybeScrollToFragment();
166+
}
167+
168+
return result;
151169
}
152170

153171
/** Go back to previous page in the window.history. */
@@ -188,7 +206,12 @@ export class Router implements ComponentInterface {
188206
return false;
189207
}
190208

191-
this.setSegments(segments, direction);
209+
// navChanged is an outlet-driven URL sync. Only keep the fragment when
210+
// the path is unchanged; on a real navigation it refers to an anchor on
211+
// the page being left and would be stale.
212+
const newPath = generatePath(segments);
213+
const fragment = newPath === this.previousPath ? this.getFragment() : undefined;
214+
this.setSegments(segments, direction, undefined, fragment);
192215

193216
await this.safeWriteNavState(outlet, chain, ROUTER_INTENT_NONE, segments, null, ids.length);
194217
return true;
@@ -245,8 +268,8 @@ export class Router implements ComponentInterface {
245268
let redirectFrom: string[] | null = null;
246269

247270
if (redirect) {
248-
const { segments: toSegments, queryString } = redirect.to!;
249-
this.setSegments(toSegments, direction, queryString);
271+
const { segments: toSegments, queryString, fragment } = redirect.to!;
272+
this.setSegments(toSegments, direction, queryString, fragment);
250273
redirectFrom = redirect.from;
251274
segments = toSegments;
252275
}
@@ -361,15 +384,49 @@ export class Router implements ComponentInterface {
361384
return changed;
362385
}
363386

364-
private setSegments(segments: string[], direction: RouterDirection, queryString?: string) {
387+
private setSegments(segments: string[], direction: RouterDirection, queryString?: string, fragment?: string) {
365388
this.state++;
366-
writeSegments(window.history, this.root, this.useHash, segments, direction, this.state, queryString);
389+
// Every URL write invalidates any in-flight fragment scroll: a newer nav
390+
// (with or without a fragment, successful or not) should always supersede.
391+
this.fragmentScrollToken++;
392+
writeSegments(
393+
window.history,
394+
this.root,
395+
this.useHash,
396+
segments,
397+
direction,
398+
this.state,
399+
queryString,
400+
fragment
401+
);
367402
}
368403

369404
private getSegments(): string[] | null {
370405
return readSegments(window.location, this.root, this.useHash);
371406
}
372407

408+
private getFragment(): string | undefined {
409+
// In hash mode the URL fragment trails a second `#` (e.g. `#/path#anchor`);
410+
// parse the routing portion to extract it.
411+
const raw = this.useHash
412+
? parsePath(window.location.hash.slice(1)).fragment
413+
: window.location.hash.slice(1);
414+
return raw ? raw : undefined;
415+
}
416+
417+
/**
418+
* Fires a best-effort scroll to the current URL fragment. The scroll bails
419+
* if a newer `setSegments` advances `fragmentScrollToken` mid-flight.
420+
*/
421+
private maybeScrollToFragment() {
422+
const fragment = this.getFragment();
423+
if (!fragment) return;
424+
const token = this.fragmentScrollToken;
425+
// Fire-and-forget; the returned promise resolves only after the scroll
426+
// animation completes, which the caller does not need to await.
427+
scrollToFragment(fragment, () => token === this.fragmentScrollToken).catch(() => {});
428+
}
429+
373430
private routeChangeEvent(toSegments: string[], redirectFromSegments: string[] | null): RouterEventDetail | null {
374431
const from = this.previousPath;
375432
const to = generatePath(toSegments);

core/src/components/router/test/basic/index.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
<p><a href='#/two/second-page'>Go to page 2</a></p>
2727
<p><a href='#/two/three/hola'>Go to page 3 (hola)</a></p>
2828
<p><a href='#/two/three/something'>Go to page 3 (something)</a></p>
29-
29+
<p><ion-router-link id="link-with-fragment" href="/two/second-page#anchor">Page 2 with fragment</ion-router-link></p>
30+
<p><ion-router-link id="link-with-query-and-fragment" href="/two/three/hola?flag=true#anchor">Page 3 with query and fragment</ion-router-link></p>
31+
<div style="height: 2000px;">page-one spacer</div>
32+
<h2 id="anchor">page-one anchor (must lose to the active page's anchor)</h2>
3033
</ion-content>`;
3134
}
3235
}
@@ -42,6 +45,9 @@
4245
<ion-content>
4346
<p><a href='#/two/three/hola'>Go to page 3 (hola)</a></p>
4447
<p><a href='#/two/three/hello'>Go to page 3 (hello)</a></p>
48+
<div style="height: 2000px;">spacer</div>
49+
<h2 id="anchor">Anchor target</h2>
50+
<div style="height: 500px;">trailing spacer</div>
4551
</ion-content>`;
4652
}
4753
}
@@ -60,6 +66,7 @@
6066
<p><a href='#/two/second-page'>Go to page 2</a></p>
6167
<p><a href='#/two/'>Go to page 1</a></p>
6268
<ion-button id="btn-rel" href="./relative?param=1">Page 3 (relative)</ion-button>
69+
<ion-button id="btn-rel-with-fragment" href="./relative#anchor">Page 3 (relative with fragment)</ion-button>
6370
<ion-button id="btn-abs" href="/two/three/absolute">Page 3 (absolute)</ion-button>
6471
</ion-content>`;
6572
}

core/src/components/router/test/basic/router.e2e.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { expect } from '@playwright/test';
2+
import type { E2EPage } from '@utils/test/playwright';
23
import { configs, test } from '@utils/test/playwright';
34

5+
/**
6+
* Waits until `page-two`'s `ion-content` has scrolled past the fixture's 2000px
7+
* spacer. The anchor target sits below the spacer, so a successful fragment
8+
* scroll must move `scrollTop` well past it; a regression that scrolled by
9+
* only a handful of pixels would fail this threshold.
10+
*/
11+
const waitForAnchorScrolled = (page: E2EPage) =>
12+
page.waitForFunction(async () => {
13+
const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null;
14+
if (!content) return false;
15+
const scrollEl = await content.getScrollElement();
16+
return scrollEl.scrollTop > 1500;
17+
});
18+
419
/**
520
* This behavior does not vary across modes/directions.
621
*/
@@ -27,6 +42,166 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
2742

2843
expect(page.url()).toContain('#/two/three/absolute');
2944
});
45+
46+
test('should route when ion-router-link href contains a fragment', async ({ page }, testInfo) => {
47+
testInfo.annotations.push({
48+
type: 'issue',
49+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
50+
});
51+
const errors: string[] = [];
52+
page.on('pageerror', (e) => errors.push(e.message));
53+
page.on('console', (msg) => {
54+
if (msg.type() === 'error') errors.push(msg.text());
55+
});
56+
57+
await page.goto(`/src/components/router/test/basic#/two`, config);
58+
await page.click('#link-with-fragment');
59+
60+
await expect(page.locator('page-two')).toBeVisible();
61+
expect(page.url()).toContain('#/two/second-page#anchor');
62+
expect(errors.filter((m) => m.includes('not part of the routing set'))).toEqual([]);
63+
});
64+
65+
test('should route when ion-router-link href contains both query and fragment', async ({ page }, testInfo) => {
66+
testInfo.annotations.push({
67+
type: 'issue',
68+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
69+
});
70+
await page.goto(`/src/components/router/test/basic#/two`, config);
71+
await page.click('#link-with-query-and-fragment');
72+
73+
await expect(page.locator('page-three')).toBeVisible();
74+
expect(page.url()).toContain('#/two/three/hola?flag=true#anchor');
75+
});
76+
77+
test('should preserve the fragment when push() resolves a relative path', async ({ page }, testInfo) => {
78+
testInfo.annotations.push({
79+
type: 'issue',
80+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
81+
});
82+
await page.goto(`/src/components/router/test/basic#/two/three/hola`, config);
83+
await page.click('#btn-rel-with-fragment');
84+
85+
expect(page.url()).toContain('#/two/three/relative#anchor');
86+
});
87+
88+
test('should scroll to the fragment target after navigating', async ({ page }, testInfo) => {
89+
testInfo.annotations.push({
90+
type: 'issue',
91+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
92+
});
93+
await page.goto(`/src/components/router/test/basic#/two`, config);
94+
await page.click('#link-with-fragment');
95+
96+
await expect(page.locator('page-two #anchor')).toBeVisible();
97+
await waitForAnchorScrolled(page);
98+
});
99+
100+
test('should scroll to the fragment target on initial deep-link load', async ({ page }, testInfo) => {
101+
testInfo.annotations.push({
102+
type: 'issue',
103+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
104+
});
105+
// Land on the fixture without a fragment first so the test helper can
106+
// attach its query params (it appends them after the hash, which would
107+
// otherwise pollute the fragment). Once loaded we replaceState to a URL
108+
// that includes the fragment, then reload to simulate a true cold open.
109+
await page.goto(`/src/components/router/test/basic#/two`, config);
110+
await page.evaluate(() => {
111+
const { origin, pathname, search } = window.location;
112+
window.history.replaceState({}, '', `${origin}${pathname}${search}#/two/second-page#anchor`);
113+
});
114+
await page.reload();
115+
116+
await expect(page.locator('page-two #anchor')).toBeVisible();
117+
await waitForAnchorScrolled(page);
118+
});
119+
120+
test('should scope the fragment lookup to the active page', async ({ page }, testInfo) => {
121+
testInfo.annotations.push({
122+
type: 'issue',
123+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
124+
});
125+
// page-one and page-two both expose `id="anchor"`. page-one is kept in
126+
// the DOM as `.ion-page-hidden` after the push; a document-wide
127+
// `getElementById` would return its anchor first. The router must scope
128+
// the lookup to the active page so page-two's anchor wins.
129+
await page.goto(`/src/components/router/test/basic#/two`, config);
130+
await page.click('#link-with-fragment');
131+
132+
await expect(page.locator('page-two #anchor')).toBeVisible();
133+
await waitForAnchorScrolled(page);
134+
135+
// page-one is still in the DOM but should not have been scrolled.
136+
const pageOneScrollTop = await page.evaluate(async () => {
137+
const content = document.querySelector('page-one ion-content') as HTMLIonContentElement | null;
138+
if (!content) return 0;
139+
const scrollEl = await content.getScrollElement();
140+
return scrollEl.scrollTop;
141+
});
142+
expect(pageOneScrollTop).toBeLessThan(100);
143+
});
144+
145+
test('should drop a stale fragment when navChanged fires for a different path', async ({ page }, testInfo) => {
146+
testInfo.annotations.push({
147+
type: 'issue',
148+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
149+
});
150+
// Land on a URL with a fragment, then trigger a tab switch. The tab
151+
// outlet emits `navChanged` for the new path; the fragment referred to
152+
// an anchor on the previous page and must not survive the rewrite.
153+
await page.goto(`/src/components/router/test/basic#/two/second-page#anchor`, config);
154+
await expect(page.locator('page-two')).toBeVisible();
155+
156+
await page.click('#tab-button-tab-one');
157+
158+
await expect(page.locator('tab-one')).toBeVisible();
159+
await page.waitForFunction(() => !window.location.hash.includes('#anchor'));
160+
expect(page.url()).not.toContain('#anchor');
161+
});
162+
163+
test('should cancel an in-flight fragment scroll when a newer navigation supersedes it', async ({
164+
page,
165+
}, testInfo) => {
166+
testInfo.annotations.push({
167+
type: 'issue',
168+
description: 'https://github.com/ionic-team/ionic-framework/issues/19365',
169+
});
170+
// Two rapid pushes: the first targets a fragment (begins polling +
171+
// smooth scroll), the second arrives before the first lands and clears
172+
// the fragment. The cancellation token must abort the first scroll so
173+
// we end up at the top of the page, not parked at #anchor.
174+
await page.goto(`/src/components/router/test/basic#/two`, config);
175+
await expect(page.locator('page-one')).toBeVisible();
176+
177+
await page.evaluate(async () => {
178+
const router = document.querySelector('ion-router') as HTMLIonRouterElement;
179+
router.push('/two/second-page#anchor');
180+
await router.push('/two/second-page');
181+
});
182+
183+
await expect(page.locator('page-two')).toBeVisible();
184+
// Wait for page-two's scrollTop to stabilise across two consecutive
185+
// frames. A scroll triggered by the un-cancelled first push would
186+
// still be animating when the assertion runs.
187+
await page.waitForFunction(async () => {
188+
const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null;
189+
if (!content) return false;
190+
const scrollEl = await content.getScrollElement();
191+
const first = scrollEl.scrollTop;
192+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
193+
return scrollEl.scrollTop === first;
194+
});
195+
196+
const pageTwoScrollTop = await page.evaluate(async () => {
197+
const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null;
198+
if (!content) return -1;
199+
const scrollEl = await content.getScrollElement();
200+
return scrollEl.scrollTop;
201+
});
202+
expect(pageTwoScrollTop).toBeLessThan(100);
203+
expect(page.url()).not.toContain('#anchor');
204+
});
30205
});
31206

32207
test.describe(title('router: tabs'), () => {

0 commit comments

Comments
 (0)