Skip to content

Commit 6b5afec

Browse files
authored
fix(angular): update route snapshot on query param navigation (#31534)
Issue number: resolves #24587 --------- ## What is the current behavior? Currently, the `ActivatedRoute` that `ion-router-outlet` injects into a page copies `snapshot` once, when the page is activated. A navigation that only changes query params reuses the page without re-activating the outlet, so `route.snapshot` stays stale (e.g., `snapshot.queryParamMap.get('foo')` is `null` after navigating to `?foo=bar`). ## What is the new behavior? The proxy's `snapshot` now reads from the route it stands in for, so it always matches the current URL. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information The issue's broken relative links stopped reproducing once Angular started building URL trees from the snapshot itself (all supported versions do). The stale snapshot behind it was still there. The new spec also covers relative links so the original symptom stays fixed, but reverting this fix will not break that test because it's just to make sure this issue doesn't come up again even though it's technically not a problem right now.
1 parent 3e3544e commit 6b5afec

7 files changed

Lines changed: 114 additions & 2 deletions

File tree

‎packages/angular/src/common/directives/navigation/router-outlet.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit {
517517

518518
proxy._futureSnapshot = (activatedRoute as any)._futureSnapshot;
519519
proxy._routerState = (activatedRoute as any)._routerState;
520-
proxy.snapshot = activatedRoute.snapshot;
520+
bindProxySnapshot(proxy, activatedRoute);
521521
proxy.outlet = activatedRoute.outlet;
522522
proxy.component = activatedRoute.component;
523523

@@ -561,14 +561,26 @@ export abstract class IonRouterOutlet implements OnDestroy, OnInit {
561561

562562
(proxy as any)._futureSnapshot = (activatedRoute as any)._futureSnapshot;
563563
(proxy as any)._routerState = (activatedRoute as any)._routerState;
564-
proxy.snapshot = activatedRoute.snapshot;
564+
bindProxySnapshot(proxy, activatedRoute);
565565
proxy.outlet = activatedRoute.outlet;
566566
proxy.component = activatedRoute.component;
567567

568568
this.currentActivatedRoute$.next({ component, activatedRoute });
569569
}
570570
}
571571

572+
/**
573+
* A query params change reuses the page and updates the route's snapshot without
574+
* re-activating the outlet, so the proxy reads it live instead of copying it once.
575+
* It's configurable because `updateActivatedRouteProxy` rebinds it.
576+
*/
577+
const bindProxySnapshot = (proxy: ActivatedRoute, activatedRoute: ActivatedRoute): void => {
578+
Object.defineProperty(proxy, 'snapshot', {
579+
get: () => activatedRoute.snapshot,
580+
configurable: true,
581+
});
582+
};
583+
572584
class OutletInjector implements Injector {
573585
constructor(private route: ActivatedRoute, private childContexts: ChildrenOutletContexts, private parent: Injector) {}
574586

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Router Outlet: query params', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/standalone/router-outlet-query-params');
6+
});
7+
8+
test('should resolve relative links after navigating to the same page with query params', async ({ page }, testInfo) => {
9+
testInfo.annotations.push({
10+
type: 'issue',
11+
description: 'https://github.com/ionic-team/ionic-framework/issues/24587',
12+
});
13+
14+
await page.locator('#set-query-params').click();
15+
await expect(page).toHaveURL(/\/standalone\/router-outlet-query-params\?foo=bar$/);
16+
17+
await page.locator('#go-to-details').click();
18+
19+
await expect(page).toHaveURL(/\/standalone\/router-outlet-query-params\/details$/);
20+
await expect(page.locator('app-router-outlet-query-params-details')).toBeVisible();
21+
});
22+
23+
test('should update the route snapshot after navigating to the same page with query params', async ({ page }, testInfo) => {
24+
testInfo.annotations.push({
25+
type: 'issue',
26+
description: 'https://github.com/ionic-team/ionic-framework/issues/24587',
27+
});
28+
29+
await page.locator('#set-query-params').click();
30+
await expect(page).toHaveURL(/\/standalone\/router-outlet-query-params\?foo=bar$/);
31+
32+
await page.locator('#read-snapshot').click();
33+
34+
await expect(page.locator('#snapshot-foo')).toHaveText('bar');
35+
});
36+
});

‎packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ export const routes: Routes = [
3636
{ path: 'modal-options-generic', loadComponent: () => import('../modal-options-generic/modal-options-generic.component').then(c => c.ModalOptionsGenericComponent) },
3737
{ path: 'popover-custom-injector', loadComponent: () => import('../popover-custom-injector/popover-custom-injector.component').then(c => c.PopoverCustomInjectorComponent) },
3838
{ path: 'router-outlet', loadComponent: () => import('../router-outlet/router-outlet.component').then(c => c.RouterOutletComponent) },
39+
{
40+
path: 'router-outlet-query-params',
41+
loadComponent: () => import('../router-outlet-query-params/router-outlet-query-params-parent.component').then(c => c.RouterOutletQueryParamsParentComponent),
42+
children: [
43+
{ path: '', loadComponent: () => import('../router-outlet-query-params/router-outlet-query-params.component').then(c => c.RouterOutletQueryParamsComponent) },
44+
{ path: 'details', loadComponent: () => import('../router-outlet-query-params/router-outlet-query-params-details.component').then(c => c.RouterOutletQueryParamsDetailsComponent) },
45+
]
46+
},
3947
{ path: 'back-button', loadComponent: () => import('../back-button/back-button.component').then(c => c.BackButtonComponent) },
4048
{ path: 'router-link', loadComponent: () => import('../router-link/router-link.component').then(c => c.RouterLinkComponent) },
4149
{ path: 'nav', loadComponent: () => import('../nav/nav.component').then(c => c.NavComponent) },

‎packages/angular/test/base/src/app/standalone/home-page/home-page.component.html‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@
7979
Router Outlet Test
8080
</ion-label>
8181
</ion-item>
82+
<ion-item routerLink="/standalone/router-outlet-query-params">
83+
<ion-label>
84+
Router Outlet Query Params Test
85+
</ion-label>
86+
</ion-item>
8287
<ion-item routerLink="/standalone/split-pane">
8388
<ion-label>
8489
Split Pane Test
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Component } from '@angular/core';
2+
import { IonContent } from '@ionic/angular';
3+
4+
@Component({
5+
selector: 'app-router-outlet-query-params-details',
6+
template: `
7+
<ion-content>
8+
<h1>Details</h1>
9+
</ion-content>
10+
`,
11+
standalone: true,
12+
imports: [IonContent],
13+
})
14+
export class RouterOutletQueryParamsDetailsComponent {}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Component } from '@angular/core';
2+
import { IonRouterOutlet } from '@ionic/angular';
3+
4+
@Component({
5+
selector: 'app-router-outlet-query-params-parent',
6+
template: `<ion-router-outlet></ion-router-outlet>`,
7+
standalone: true,
8+
imports: [IonRouterOutlet],
9+
})
10+
export class RouterOutletQueryParamsParentComponent {}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Component, inject } from '@angular/core';
2+
import { ActivatedRoute, RouterLink } from '@angular/router';
3+
import { IonContent, IonRouterLinkWithHref } from '@ionic/angular';
4+
5+
@Component({
6+
selector: 'app-router-outlet-query-params',
7+
template: `
8+
<ion-content>
9+
<h1>Page One</h1>
10+
<a id="set-query-params" [routerLink]="[]" [queryParams]="{ foo: 'bar' }">Stay with query params</a><br />
11+
<a id="go-to-details" [routerLink]="['details']">Go to details</a><br />
12+
<button id="read-snapshot" (click)="readSnapshot()">Read snapshot</button>
13+
<p id="snapshot-foo">{{ snapshotFoo }}</p>
14+
</ion-content>
15+
`,
16+
standalone: true,
17+
imports: [RouterLink, IonContent, IonRouterLinkWithHref],
18+
})
19+
export class RouterOutletQueryParamsComponent {
20+
private route = inject(ActivatedRoute);
21+
22+
snapshotFoo = '';
23+
24+
readSnapshot() {
25+
this.snapshotFoo = this.route.snapshot.queryParamMap.get('foo') ?? 'none';
26+
}
27+
}

0 commit comments

Comments
 (0)