11import { expect } from '@playwright/test' ;
2+ import type { E2EPage } from '@utils/test/playwright' ;
23import { 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