diff --git a/core/src/components/router/router.tsx b/core/src/components/router/router.tsx index a5c0c67bf0e..36287043127 100644 --- a/core/src/components/router/router.tsx +++ b/core/src/components/router/router.tsx @@ -9,7 +9,7 @@ import type { NavigationHookResult } from '../route/route-interface'; import { ROUTER_INTENT_BACK, ROUTER_INTENT_FORWARD, ROUTER_INTENT_NONE } from './utils/constants'; import { printRedirects, printRoutes } from './utils/debug'; -import { readNavState, waitUntilNavNode, writeNavState } from './utils/dom'; +import { readNavState, scrollToFragment, waitUntilNavNode, writeNavState } from './utils/dom'; import type { RouteChain, RouterDirection, RouterEventDetail } from './utils/interface'; import { findChainForIDs, findChainForSegments, findRouteRedirect } from './utils/matching'; import { readRedirects, readRoutes } from './utils/parser'; @@ -24,6 +24,7 @@ export class Router implements ComponentInterface { private state = 0; private lastState = 0; private waitPromise?: Promise; + private fragmentScrollToken = 0; @Element() el!: HTMLElement; @@ -67,11 +68,18 @@ export class Router implements ComponentInterface { if (typeof canProceed === 'object') { const { redirect } = canProceed; const path = parsePath(redirect); - this.setSegments(path.segments, ROUTER_INTENT_NONE, path.queryString); - await this.writeNavStateRoot(path.segments, ROUTER_INTENT_NONE); + this.setSegments(path.segments, ROUTER_INTENT_NONE, path.queryString, path.fragment); + const result = await this.writeNavStateRoot(path.segments, ROUTER_INTENT_NONE); + if (result) { + this.maybeScrollToFragment(); + } } - } else { - await this.onRoutesChanged(); + return; + } + + const result = await this.onRoutesChanged(); + if (result) { + this.maybeScrollToFragment(); } } @@ -93,7 +101,12 @@ export class Router implements ComponentInterface { return false; } } - return this.writeNavStateRoot(segments, direction); + const result = await this.writeNavStateRoot(segments, direction); + if (result) { + this.maybeScrollToFragment(); + } + + return result; } @Listen('ionBackButton', { target: 'document' }) @@ -132,7 +145,7 @@ export class Router implements ComponentInterface { const currentPath = this.previousPath ?? '/'; // Convert currentPath to an URL by pre-pending a protocol and a host to resolve the relative path. const url = new URL(path, `https://host/${currentPath}`); - path = url.pathname + url.search; + path = url.pathname + url.search + url.hash; } let parsedPath = parsePath(path); @@ -146,8 +159,13 @@ export class Router implements ComponentInterface { } } - this.setSegments(parsedPath.segments, direction, parsedPath.queryString); - return this.writeNavStateRoot(parsedPath.segments, direction, animation); + this.setSegments(parsedPath.segments, direction, parsedPath.queryString, parsedPath.fragment); + const result = await this.writeNavStateRoot(parsedPath.segments, direction, animation); + if (result) { + this.maybeScrollToFragment(); + } + + return result; } /** Go back to previous page in the window.history. */ @@ -188,7 +206,12 @@ export class Router implements ComponentInterface { return false; } - this.setSegments(segments, direction); + // navChanged is an outlet-driven URL sync. Only keep the fragment when + // the path is unchanged; on a real navigation it refers to an anchor on + // the page being left and would be stale. + const newPath = generatePath(segments); + const fragment = newPath === this.previousPath ? this.getFragment() : undefined; + this.setSegments(segments, direction, undefined, fragment); await this.safeWriteNavState(outlet, chain, ROUTER_INTENT_NONE, segments, null, ids.length); return true; @@ -245,8 +268,8 @@ export class Router implements ComponentInterface { let redirectFrom: string[] | null = null; if (redirect) { - const { segments: toSegments, queryString } = redirect.to!; - this.setSegments(toSegments, direction, queryString); + const { segments: toSegments, queryString, fragment } = redirect.to!; + this.setSegments(toSegments, direction, queryString, fragment); redirectFrom = redirect.from; segments = toSegments; } @@ -361,15 +384,38 @@ export class Router implements ComponentInterface { return changed; } - private setSegments(segments: string[], direction: RouterDirection, queryString?: string) { + private setSegments(segments: string[], direction: RouterDirection, queryString?: string, fragment?: string) { this.state++; - writeSegments(window.history, this.root, this.useHash, segments, direction, this.state, queryString); + // Every URL write invalidates any in-flight fragment scroll: a newer nav + // (with or without a fragment, successful or not) should always supersede. + this.fragmentScrollToken++; + writeSegments(window.history, this.root, this.useHash, segments, direction, this.state, queryString, fragment); } private getSegments(): string[] | null { return readSegments(window.location, this.root, this.useHash); } + private getFragment(): string | undefined { + // In hash mode the URL fragment trails a second `#` (e.g. `#/path#anchor`); + // parse the routing portion to extract it. + const raw = this.useHash ? parsePath(window.location.hash.slice(1)).fragment : window.location.hash.slice(1); + return raw ? raw : undefined; + } + + /** + * Fires a best-effort scroll to the current URL fragment. The scroll bails + * if a newer `setSegments` advances `fragmentScrollToken` mid-flight. + */ + private maybeScrollToFragment() { + const fragment = this.getFragment(); + if (!fragment) return; + const token = this.fragmentScrollToken; + // Fire-and-forget; the returned promise resolves only after the scroll + // animation completes, which the caller does not need to await. + scrollToFragment(fragment, () => token === this.fragmentScrollToken).catch(() => {}); + } + private routeChangeEvent(toSegments: string[], redirectFromSegments: string[] | null): RouterEventDetail | null { const from = this.previousPath; const to = generatePath(toSegments); diff --git a/core/src/components/router/test/basic/index.html b/core/src/components/router/test/basic/index.html index 406adb95f49..12d459caa50 100644 --- a/core/src/components/router/test/basic/index.html +++ b/core/src/components/router/test/basic/index.html @@ -26,7 +26,10 @@

Go to page 2

Go to page 3 (hola)

Go to page 3 (something)

- +

Page 2 with fragment

+

Page 3 with query and fragment

+
page-one spacer
+

page-one anchor (must lose to the active page's anchor)

`; } } @@ -42,6 +45,9 @@

Go to page 3 (hola)

Go to page 3 (hello)

+
spacer
+

Anchor target

+
trailing spacer
`; } } @@ -60,6 +66,7 @@

Go to page 2

Go to page 1

Page 3 (relative) + Page 3 (relative with fragment) Page 3 (absolute) `; } diff --git a/core/src/components/router/test/basic/router.e2e.ts b/core/src/components/router/test/basic/router.e2e.ts index 33f4e37d33b..ed9faab3532 100644 --- a/core/src/components/router/test/basic/router.e2e.ts +++ b/core/src/components/router/test/basic/router.e2e.ts @@ -1,6 +1,21 @@ import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; import { configs, test } from '@utils/test/playwright'; +/** + * Waits until `page-two`'s `ion-content` has scrolled past the fixture's 2000px + * spacer. The anchor target sits below the spacer, so a successful fragment + * scroll must move `scrollTop` well past it; a regression that scrolled by + * only a handful of pixels would fail this threshold. + */ +const waitForAnchorScrolled = (page: E2EPage) => + page.waitForFunction(async () => { + const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null; + if (!content) return false; + const scrollEl = await content.getScrollElement(); + return scrollEl.scrollTop > 1500; + }); + /** * This behavior does not vary across modes/directions. */ @@ -27,6 +42,188 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(page.url()).toContain('#/two/three/absolute'); }); + + test('should route when ion-router-link href contains a fragment', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-fragment'); + + await expect(page.locator('page-two')).toBeVisible(); + expect(page.url()).toContain('#/two/second-page#anchor'); + expect(errors.filter((m) => m.includes('not part of the routing set'))).toEqual([]); + }); + + test('should route when ion-router-link href contains both query and fragment', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-query-and-fragment'); + + await expect(page.locator('page-three')).toBeVisible(); + expect(page.url()).toContain('#/two/three/hola?flag=true#anchor'); + }); + + test('should preserve the fragment when push() resolves a relative path', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + await page.goto(`/src/components/router/test/basic#/two/three/hola`, config); + await page.click('#btn-rel-with-fragment'); + + expect(page.url()).toContain('#/two/three/relative#anchor'); + }); + + test('should scroll to the fragment target after navigating', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-fragment'); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + }); + + test('should scroll to the fragment target on initial deep-link load', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Land on the fixture without a fragment first so the test helper can + // attach its query params (it appends them after the hash, which would + // otherwise pollute the fragment). Once loaded we replaceState to a URL + // that includes the fragment, then reload to simulate a true cold open. + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.evaluate(() => { + const { origin, pathname, search } = window.location; + window.history.replaceState({}, '', `${origin}${pathname}${search}#/two/second-page#anchor`); + }); + await page.reload(); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + }); + + test('should scroll on deep-link load even when an inactive tab has hydrated', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Inactive `ion-tab` elements carry `.ion-page` but use `.tab-hidden` + // instead of `.ion-page-hidden`. The fixture's inline `tab-four` is one + // such sibling. Waiting for it to hydrate before reload makes the + // active-page lookup deterministic across runs. + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.waitForFunction(() => !!document.querySelector('ion-tab[tab="tab-four"].hydrated')); + await page.evaluate(() => { + const { origin, pathname, search } = window.location; + window.history.replaceState({}, '', `${origin}${pathname}${search}#/two/second-page#anchor`); + }); + await page.reload(); + await page.waitForFunction(() => !!document.querySelector('ion-tab[tab="tab-four"].hydrated')); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + }); + + test('should scope the fragment lookup to the active page', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // page-one and page-two both expose `id="anchor"`. page-one is kept in + // the DOM as `.ion-page-hidden` after the push; a document-wide + // `getElementById` would return its anchor first. The router must scope + // the lookup to the active page so page-two's anchor wins. + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-fragment'); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + + // page-one is still in the DOM but should not have been scrolled. + const pageOneScrollTop = await page.evaluate(async () => { + const content = document.querySelector('page-one ion-content') as HTMLIonContentElement | null; + if (!content) return 0; + const scrollEl = await content.getScrollElement(); + return scrollEl.scrollTop; + }); + expect(pageOneScrollTop).toBeLessThan(100); + }); + + test('should drop a stale fragment when navChanged fires for a different path', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Land on a URL with a fragment, then trigger a tab switch. The tab + // outlet emits `navChanged` for the new path; the fragment referred to + // an anchor on the previous page and must not survive the rewrite. + await page.goto(`/src/components/router/test/basic#/two/second-page#anchor`, config); + await expect(page.locator('page-two')).toBeVisible(); + + await page.click('#tab-button-tab-one'); + + await expect(page.locator('tab-one')).toBeVisible(); + await page.waitForFunction(() => !window.location.hash.includes('#anchor')); + expect(page.url()).not.toContain('#anchor'); + }); + + test('should cancel an in-flight fragment scroll when a newer navigation supersedes it', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Two rapid pushes: the first targets a fragment (begins polling + + // smooth scroll), the second arrives before the first lands and clears + // the fragment. The cancellation token must abort the first scroll so + // we end up at the top of the page, not parked at #anchor. + await page.goto(`/src/components/router/test/basic#/two`, config); + await expect(page.locator('page-one')).toBeVisible(); + + await page.evaluate(async () => { + const router = document.querySelector('ion-router') as HTMLIonRouterElement; + router.push('/two/second-page#anchor'); + await router.push('/two/second-page'); + }); + + await expect(page.locator('page-two')).toBeVisible(); + // Wait for page-two's scrollTop to stabilise across two consecutive + // frames. A scroll triggered by the un-cancelled first push would + // still be animating when the assertion runs. + await page.waitForFunction(async () => { + const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null; + if (!content) return false; + const scrollEl = await content.getScrollElement(); + const first = scrollEl.scrollTop; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return scrollEl.scrollTop === first; + }); + + const pageTwoScrollTop = await page.evaluate(async () => { + const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null; + if (!content) return -1; + const scrollEl = await content.getScrollElement(); + return scrollEl.scrollTop; + }); + expect(pageTwoScrollTop).toBeLessThan(100); + expect(page.url()).not.toContain('#anchor'); + }); }); test.describe(title('router: tabs'), () => { diff --git a/core/src/components/router/test/dom.spec.tsx b/core/src/components/router/test/dom.spec.tsx new file mode 100644 index 00000000000..c8b47b485e7 --- /dev/null +++ b/core/src/components/router/test/dom.spec.tsx @@ -0,0 +1,99 @@ +import { scrollToFragment } from '../utils/dom'; + +describe('scrollToFragment', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+

target

+
+ `; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should return false for null/undefined/empty fragment', async () => { + expect(await scrollToFragment(undefined)).toBe(false); + expect(await scrollToFragment('')).toBe(false); + }); + + it('should find and scroll to an existing target', async () => { + // No ion-content here, so the function falls back to scrollIntoView. We + // assert the truthy return (target found, scroll attempted) rather than + // jsdom-specific scroll state. + expect(await scrollToFragment('target')).toBe(true); + }); + + it('should not match similarly named fragments', async () => { + // The lookup uses an exact `#id` selector, so partial/substring matches + // must not resolve. Regression guard for any future refactor that swaps + // the selector for fuzzy matching. + expect(await scrollToFragment('target2')).toBe(false); + expect(await scrollToFragment('targe')).toBe(false); + }); + + it('should return false when shouldContinue is false from the start', async () => { + // The target exists in the DOM, so the only reason this can return false + // is the cancellation predicate firing inside findFragmentTarget. + expect(await scrollToFragment('target', () => false)).toBe(false); + }); + + it('should respect shouldContinue mid-poll when the target is missing', async () => { + // Target is not in the DOM; findFragmentTarget enters its poll loop. + // shouldContinue flips false on the third call, which must abort the loop. + let calls = 0; + const result = await scrollToFragment('missing', () => ++calls <= 2); + expect(result).toBe(false); + expect(calls).toBeGreaterThan(2); + }); + + it('should not call shouldContinue after returning early on null fragment', async () => { + let calls = 0; + await scrollToFragment(undefined, () => { + calls++; + return true; + }); + expect(calls).toBe(0); + }); + + it('should reject a target whose ancestor is .ion-page-hidden', async () => { + // Back-stack pages stay in the DOM with `.ion-page-hidden`. A target + // matched only on a hidden page must not win. + document.body.innerHTML = ` +
+

stale

+
+ `; + expect(await scrollToFragment('stale')).toBe(false); + }); + + it('should reject a target whose ancestor is .tab-hidden', async () => { + // Inactive `ion-tab` elements carry `.ion-page` but use `.tab-hidden` + // (not `.ion-page-hidden`). A target inside an inactive tab must not win; + // a naive "last `.ion-page:not(.ion-page-hidden)`" pick would otherwise + // land on it. + document.body.innerHTML = ` +
+

stale

+
+ `; + expect(await scrollToFragment('in-inactive-tab')).toBe(false); + }); + + it('should accept a target inside a sibling non-hidden ion-page', async () => { + // During transitions multiple `.ion-page` elements may briefly coexist + // without `.ion-page-hidden`. A target inside any non-hidden page must + // still resolve. + document.body.innerHTML = ` +
+

in hidden page

+
+
+

other

+
+ `; + // Lookup of 'other' must succeed even though a hidden sibling page exists. + expect(await scrollToFragment('other')).toBe(true); + }); +}); diff --git a/core/src/components/router/test/path.spec.tsx b/core/src/components/router/test/path.spec.tsx index 48e366aebca..e8e398d9389 100644 --- a/core/src/components/router/test/path.spec.tsx +++ b/core/src/components/router/test/path.spec.tsx @@ -48,6 +48,47 @@ describe('parsePath', () => { expect(parsePath('path/to/file.js?').queryString).toEqual(''); expect(parsePath('path/to/file.js?a=b').queryString).toEqual('a=b'); }); + + it('should strip the fragment from segments and return it', () => { + const result = parsePath('/catalog#pens'); + expect(result.segments).toEqual(['catalog']); + expect(result.fragment).toEqual('pens'); + }); + + it('should parse fragment alongside query string (query first)', () => { + const result = parsePath('/catalog?x=1#pens'); + expect(result.segments).toEqual(['catalog']); + expect(result.queryString).toEqual('x=1'); + expect(result.fragment).toEqual('pens'); + }); + + it('should treat "?" inside fragment as part of the fragment', () => { + // Per RFC 3986 the fragment starts at the first "#" and runs to the end. + const result = parsePath('/catalog#pens?x=1'); + expect(result.segments).toEqual(['catalog']); + expect(result.queryString).toBeUndefined(); + expect(result.fragment).toEqual('pens?x=1'); + }); + + it('should parse fragment-only path', () => { + const result = parsePath('#pens'); + expect(result.segments).toEqual(['']); + expect(result.fragment).toEqual('pens'); + }); + + it('should leave fragment undefined when there is no "#"', () => { + expect(parsePath('/catalog').fragment).toBeUndefined(); + expect(parsePath('/catalog?x=1').fragment).toBeUndefined(); + expect(parsePath(null).fragment).toBeUndefined(); + expect(parsePath(undefined).fragment).toBeUndefined(); + }); + + it('should preserve percent-encoded characters in the fragment', () => { + // parsePath keeps the fragment in its URL-encoded form; decoding for id + // matching is the consumer's responsibility (see `scrollToFragment`). + expect(parsePath('/catalog#sec%20one').fragment).toEqual('sec%20one'); + expect(parsePath('/catalog#%E4%B8%AD%E6%96%87').fragment).toEqual('%E4%B8%AD%E6%96%87'); + }); }); describe('generatePath', () => { @@ -243,6 +284,31 @@ describe('writeSegments', () => { writeSegments(history, '/path/to/', true, ['second', 'page'], ROUTER_INTENT_FORWARD, 123, 'flag=true'); expect(history.pushState).toHaveBeenCalledWith(123, '', '#/path/to/second/page?flag=true'); }); + + it('should append the fragment after the query string (no hash)', () => { + const history = mockHistory(); + writeSegments(history, '/', false, ['catalog'], ROUTER_INTENT_FORWARD, 1, 'x=1', 'pens'); + expect(history.pushState).toHaveBeenCalledWith(1, '', '/catalog?x=1#pens'); + }); + + it('should append the fragment when there is no query string (no hash)', () => { + const history = mockHistory(); + writeSegments(history, '/', false, ['catalog'], ROUTER_INTENT_FORWARD, 1, undefined, 'pens'); + expect(history.pushState).toHaveBeenCalledWith(1, '', '/catalog#pens'); + }); + + it('should append the fragment in hash routing mode', () => { + // In hash routing the routing "#" wraps the path; the URL fragment is a second "#" appended at the end. + const history = mockHistory(); + writeSegments(history, '/', true, ['catalog'], ROUTER_INTENT_FORWARD, 1, undefined, 'pens'); + expect(history.pushState).toHaveBeenCalledWith(1, '', '#/catalog#pens'); + }); + + it('should omit the fragment when none is provided', () => { + const history = mockHistory(); + writeSegments(history, '/', false, ['catalog'], ROUTER_INTENT_FORWARD, 1); + expect(history.pushState).toHaveBeenCalledWith(1, '', '/catalog'); + }); }); function mockHistory(): History { diff --git a/core/src/components/router/utils/dom.ts b/core/src/components/router/utils/dom.ts index c10b34d0840..d2b23e095dc 100644 --- a/core/src/components/router/utils/dom.ts +++ b/core/src/components/router/utils/dom.ts @@ -1,4 +1,5 @@ -import { componentOnReady } from '@utils/helpers'; +import { findClosestIonContent, getScrollElement, isIonContent } from '@utils/content'; +import { componentOnReady, raf } from '@utils/helpers'; import { printIonError } from '@utils/logging'; import type { AnimationBuilder } from '../../../interface'; @@ -81,6 +82,126 @@ export const readNavState = async (root: HTMLElement | undefined) => { return { ids, outlet }; }; +/** Max animation frames `scrollToFragment` polls while waiting for the target to mount. */ +const FRAGMENT_POLL_FRAMES = 30; + +/** Duration (ms) of the smooth-scroll animation that lands on the fragment target. */ +const FRAGMENT_SCROLL_DURATION = 300; + +const nextFrame = () => new Promise((resolve) => raf(() => resolve())); + +/** + * Returns true when `el` lives inside an active `.ion-page`. `ion-page-hidden` + * marks nav back-stack entries; `tab-hidden` marks inactive `ion-tab` elements. + * Either class on the page's ancestor chain disqualifies it. When no `.ion-page` + * exists in the document at all (non-router pages), the candidate is accepted + * so plain anchors still work. + */ +const isInActivePage = (el: HTMLElement): boolean => { + const page = el.closest('.ion-page'); + if (page === null) { + return document.querySelector('.ion-page') === null; + } + return page.closest('.ion-page-hidden, .tab-hidden') === null; +}; + +/** + * Polls across animation frames for an element matching `fragment` that lives + * in the active page. Scoping by "last `.ion-page:not(.ion-page-hidden)`" is + * unreliable: inactive `ion-tab` siblings carry `.ion-page` (gated by + * `.tab-hidden`, not `.ion-page-hidden`) and can be ordered after the leaf. + * Instead, locate candidates globally and walk them from last to first, + * accepting the deepest one whose `.ion-page` ancestor is not hidden. The + * last-to-first order preserves leaf-most preference for nested outlets. + */ +const findFragmentTarget = async (fragment: string, shouldContinue: () => boolean): Promise => { + // CSS.escape is unavailable on very old WebViews; the fallback path uses + // `getElementById` and drops the legacy `` branch. + const canEscape = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'; + const escaped = canEscape ? CSS.escape(fragment) : null; + + for (let i = 0; i < FRAGMENT_POLL_FRAMES; i++) { + if (!shouldContinue()) return null; + + let candidates: HTMLElement[] = []; + if (escaped !== null) { + try { + candidates = [...document.querySelectorAll(`#${escaped}, a[name="${escaped}"]`)]; + } catch { + candidates = [...document.querySelectorAll(`#${escaped}`)]; + } + } else { + const byId = document.getElementById(fragment); + if (byId !== null) candidates = [byId]; + } + + for (let j = candidates.length - 1; j >= 0; j--) { + if (isInActivePage(candidates[j])) { + return candidates[j]; + } + } + await nextFrame(); + } + + return null; +}; + +/** + * Scrolls to the element whose id matches `fragment`, falling back to a legacy + * `` target. When the target lives inside an `ion-content`, the + * scroll uses its smooth-animated scroll API; otherwise it falls back to + * `Element.scrollIntoView`. + * + * `shouldContinue` lets callers cancel in-flight scrolls when a newer + * navigation supersedes this one. It is checked between async steps. + */ +export const scrollToFragment = async ( + fragment: string | undefined, + shouldContinue: () => boolean = () => true +): Promise => { + if (fragment == null || fragment === '') { + return false; + } + + // URL fragments are percent-encoded but element ids are not; decode for + // matching per the HTML spec's indicated-element resolution. + let decoded: string; + try { + decoded = decodeURIComponent(fragment); + } catch { + decoded = fragment; + } + + const target = await findFragmentTarget(decoded, shouldContinue); + if (!target || !shouldContinue()) { + return false; + } + + // Best-effort scroll: swallow exceptions if the page tears down mid-animation. + try { + const contentHost = findClosestIonContent(target); + if (contentHost && isIonContent(contentHost)) { + const content = contentHost as HTMLIonContentElement; + const scrollEl = await getScrollElement(content); + // Yield one frame so the newly mounted target's layout is stable + // before we measure its rect. + await nextFrame(); + if (!shouldContinue()) return false; + const targetRect = target.getBoundingClientRect(); + const scrollRect = scrollEl.getBoundingClientRect(); + const top = targetRect.top - scrollRect.top + scrollEl.scrollTop; + // Preserve scrollLeft so RTL and horizontally-scrolling pages aren't reset. + await content.scrollToPoint(scrollEl.scrollLeft, top, FRAGMENT_SCROLL_DURATION); + } else { + target.scrollIntoView({ behavior: 'smooth' }); + } + return true; + } catch (e) { + printIonError('[ion-router] - Exception in scrollToFragment:', e); + return false; + } +}; + export const waitUntilNavNode = (): Promise => { if (searchNavNode(document.body)) { return Promise.resolve(); diff --git a/core/src/components/router/utils/interface.ts b/core/src/components/router/utils/interface.ts index caeb6e57c08..c0457da9680 100644 --- a/core/src/components/router/utils/interface.ts +++ b/core/src/components/router/utils/interface.ts @@ -57,6 +57,8 @@ export interface ParsedRoute { segments: string[]; /** Unparsed query string. */ queryString?: string; + /** URL fragment (the part after `#`), without the leading `#`. */ + fragment?: string; } export type RouterDirection = 'forward' | 'back' | 'root'; diff --git a/core/src/components/router/utils/path.ts b/core/src/components/router/utils/path.ts index 4305d84e986..dad058c56b0 100644 --- a/core/src/components/router/utils/path.ts +++ b/core/src/components/router/utils/path.ts @@ -8,7 +8,7 @@ export const generatePath = (segments: string[]): string => { return '/' + path; }; -const generateUrl = (segments: string[], useHash: boolean, queryString?: string) => { +const generateUrl = (segments: string[], useHash: boolean, queryString?: string, fragment?: string) => { let url = generatePath(segments); if (useHash) { url = '#' + url; @@ -16,6 +16,9 @@ const generateUrl = (segments: string[], useHash: boolean, queryString?: string) if (queryString !== undefined) { url += '?' + queryString; } + if (fragment !== undefined) { + url += '#' + fragment; + } return url; }; @@ -26,9 +29,10 @@ export const writeSegments = ( segments: string[], direction: RouterDirection, state: number, - queryString?: string + queryString?: string, + fragment?: string ) => { - const url = generateUrl([...parsePath(root).segments, ...segments], useHash, queryString); + const url = generateUrl([...parsePath(root).segments, ...segments], useHash, queryString, fragment); if (direction === ROUTER_INTENT_FORWARD) { history.pushState(state, '', url); } else { @@ -97,13 +101,23 @@ export const readSegments = (loc: Location, root: string, useHash: boolean): str /** * Parses the path to: * - segments an array of '/' separated parts, - * - queryString (undefined when no query string). + * - queryString (undefined when no query string), + * - fragment (undefined when no `#`). */ export const parsePath = (path: string | undefined | null): ParsedRoute => { let segments = ['']; let queryString; + let fragment; if (path != null) { + // The fragment ("#") starts a section that runs to the end of the URL. + // Anything inside it (including "?") is part of the fragment per RFC 3986. + const fragStart = path.indexOf('#'); + if (fragStart > -1) { + fragment = path.substring(fragStart + 1); + path = path.substring(0, fragStart); + } + const qsStart = path.indexOf('?'); if (qsStart > -1) { queryString = path.substring(qsStart + 1); @@ -120,5 +134,5 @@ export const parsePath = (path: string | undefined | null): ParsedRoute => { } } - return { segments, queryString }; + return { segments, queryString, fragment }; };