Skip to content

Commit 21a941e

Browse files
authored
feat!: add MinimalActivatedRouteSnapshot#title and RouterStore#title$ (#291)
# Features - Add required property `MinimalActivatedRouteSnapshot#title` to match `ActivatedRouteSnapshot#title` - Remove symbol index from `MinimalActivatedRoutesSnapshot#data` to make it serializable - Add `RouterStore#title$` selector with support for static and resolved route titles BREAKING CHANGE: - `MinimalActivatedRouteSnapshot#title` is added as a required property to match `ActivatedRouteSnapshot#title`. - `MinimalActivatedRoutesSnapshot#data` has its symbol index removed to make it serializable when a route title exists. This change is because of the internal `Symbol(RouterTitle)` key added by the Angular Router.
1 parent 21a218a commit 21a941e

File tree

7 files changed

+242
-114
lines changed

7 files changed

+242
-114
lines changed

packages/router-component-store/src/lib/@ngrx/router-store/minimal_serializer.spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe('minimal serializer', () => {
7272
pathMatch: `${prefix}-route.routeConfig.pathMatch`,
7373
redirectTo: `${prefix}-route.routeConfig.redirectTo`,
7474
outlet: `${prefix}-route.routeConfig.outlet`,
75+
title: `${prefix}-route.routeConfig.title`,
7576
},
7677
firstChild: undefined,
7778
};
@@ -95,7 +96,9 @@ function createRouteSnapshot(prefix = 'root'): any {
9596
return {
9697
params: `${prefix}-route.params`,
9798
paramMap: `${prefix}-route.paramMap`,
98-
data: `${prefix}-route.data`,
99+
data: {
100+
[`${prefix}-data`]: `${prefix}-route.data.${prefix}-data`,
101+
},
99102
url: `${prefix}-route.url`,
100103
outlet: `${prefix}-route.outlet`,
101104
routeConfig: {
@@ -104,6 +107,7 @@ function createRouteSnapshot(prefix = 'root'): any {
104107
pathMatch: `${prefix}-route.routeConfig.pathMatch`,
105108
redirectTo: `${prefix}-route.routeConfig.redirectTo`,
106109
outlet: `${prefix}-route.routeConfig.outlet`,
110+
title: `${prefix}-route.routeConfig.title`,
107111
},
108112
queryParams: `${prefix}-route.queryParams`,
109113
queryParamMap: `${prefix}-route.queryParamMap`,

packages/router-component-store/src/lib/@ngrx/router-store/minimal_serializer.ts

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,24 @@
3030
* found in the LICENSE file at https://angular.io/license
3131
*/
3232
import { Injectable } from '@angular/core';
33-
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
33+
import {
34+
ActivatedRouteSnapshot,
35+
Data,
36+
RouterStateSnapshot,
37+
} from '@angular/router';
38+
39+
type OmitSymbolIndex<TShape> = {
40+
[TShapeKey in keyof TShape as symbol extends TShapeKey
41+
? never
42+
: TShapeKey]: TShape[TShapeKey];
43+
};
44+
45+
/**
46+
* Serializable route `Data` without its symbol index, in particular without the
47+
* `Symbol.for(RouteTitle)` key as this is an internal value for the Angular
48+
* `Router`.
49+
*/
50+
export type MinimalRouteData = OmitSymbolIndex<Data>;
3451

3552
/**
3653
* Contains the information about a route associated with a component loaded in
@@ -60,12 +77,22 @@ export interface MinimalActivatedRouteSnapshot {
6077
readonly fragment: ActivatedRouteSnapshot['fragment'];
6178
/**
6279
* The static and resolved data of this route.
80+
*
81+
* @remarks
82+
* Contains serializable route `Data` without its symbol index, in particular
83+
* without the `Symbol.for(RouteTitle)` key as this is an internal value for
84+
* the Angular `Router`. Instead, we access the resolved route title through
85+
* `MinimalActivatedRouteSnapshot['title']`.
6386
*/
64-
readonly data: ActivatedRouteSnapshot['data'];
87+
readonly data: OmitSymbolIndex<ActivatedRouteSnapshot['data']>;
6588
/**
6689
* The outlet name of the route.
6790
*/
6891
readonly outlet: ActivatedRouteSnapshot['outlet'];
92+
/**
93+
* The resolved route title.
94+
*/
95+
readonly title: ActivatedRouteSnapshot['title'];
6996
/**
7097
* The first child of this route in the router state tree
7198
*/
@@ -81,36 +108,56 @@ export interface MinimalRouterStateSnapshot {
81108
readonly url: string;
82109
}
83110

111+
function objectFromEntries<TValue>(entries: [string, TValue][]): {
112+
[key: string]: TValue;
113+
} {
114+
return entries.reduce(
115+
(object, [key, value]) => ({ ...object, [key]: value }),
116+
{}
117+
);
118+
}
119+
84120
@Injectable({
85121
providedIn: 'root',
86122
})
87123
export class MinimalRouterStateSerializer {
88124
serialize(routerState: RouterStateSnapshot): MinimalRouterStateSnapshot {
89125
return {
90-
root: this.serializeRoute(routerState.root),
126+
root: this.#serializeRouteSnapshot(routerState.root),
91127
url: routerState.url,
92128
};
93129
}
94130

95-
private serializeRoute(
96-
route: ActivatedRouteSnapshot
131+
#serializeRouteData(routeData: Data): MinimalRouteData {
132+
return objectFromEntries(Object.entries(routeData));
133+
}
134+
135+
#serializeRouteSnapshot(
136+
routeSnapshot: ActivatedRouteSnapshot
97137
): MinimalActivatedRouteSnapshot {
98-
const children = route.children.map((c) => this.serializeRoute(c));
138+
const children = routeSnapshot.children.map((childRouteSnapshot) =>
139+
this.#serializeRouteSnapshot(childRouteSnapshot)
140+
);
99141
return {
100-
params: route.params,
101-
data: route.data,
102-
url: route.url,
103-
outlet: route.outlet,
104-
routeConfig: route.routeConfig
142+
params: routeSnapshot.params,
143+
data: this.#serializeRouteData(routeSnapshot.data),
144+
url: routeSnapshot.url,
145+
outlet: routeSnapshot.outlet,
146+
title: routeSnapshot.title,
147+
routeConfig: routeSnapshot.routeConfig
105148
? {
106-
path: route.routeConfig.path,
107-
pathMatch: route.routeConfig.pathMatch,
108-
redirectTo: route.routeConfig.redirectTo,
109-
outlet: route.routeConfig.outlet,
149+
path: routeSnapshot.routeConfig.path,
150+
pathMatch: routeSnapshot.routeConfig.pathMatch,
151+
redirectTo: routeSnapshot.routeConfig.redirectTo,
152+
outlet: routeSnapshot.routeConfig.outlet,
153+
title:
154+
typeof routeSnapshot.routeConfig.title === 'string'
155+
? routeSnapshot.routeConfig.title
156+
: undefined,
110157
}
111158
: null,
112-
queryParams: route.queryParams,
113-
fragment: route.fragment,
159+
queryParams: routeSnapshot.queryParams,
160+
fragment: routeSnapshot.fragment,
114161
firstChild: children[0],
115162
children,
116163
};
Lines changed: 73 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Component } from '@angular/core';
22
import { TestBed } from '@angular/core/testing';
3-
import { Router, RouterOutlet, Routes } from '@angular/router';
3+
import { Route, Router, RouterOutlet, Routes } from '@angular/router';
44
import { RouterTestingModule } from '@angular/router/testing';
55
import { firstValueFrom } from 'rxjs';
66
import { RouterStore } from '../router-store';
@@ -21,7 +21,14 @@ class DummyAppComponent {}
2121
class DummyLoginComponent {}
2222

2323
describe(`${GlobalRouterStore.name} selectors`, () => {
24-
beforeEach(async () => {
24+
async function setup({
25+
assertions = 1,
26+
title,
27+
}: {
28+
readonly assertions?: number;
29+
readonly title?: Route['title'];
30+
} = {}) {
31+
expect.assertions(assertions);
2532
const routes: Routes = [
2633
{
2734
path: 'login',
@@ -30,6 +37,7 @@ describe(`${GlobalRouterStore.name} selectors`, () => {
3037
path: ':id',
3138
component: DummyLoginComponent,
3239
data: { testData: 'test-data' },
40+
title,
3341
},
3442
],
3543
},
@@ -43,102 +51,127 @@ describe(`${GlobalRouterStore.name} selectors`, () => {
4351
const rootFixture = TestBed.createComponent(DummyAppComponent);
4452
rootFixture.autoDetectChanges(true);
4553

46-
router = TestBed.inject(Router);
47-
store = TestBed.inject(RouterStore);
48-
});
54+
const router = TestBed.inject(Router);
55+
const routerStore = TestBed.inject(RouterStore);
56+
57+
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
4958

50-
let router: Router;
51-
let store: RouterStore;
59+
return {
60+
routerStore,
61+
};
62+
}
5263

5364
it('exposes a selector for the current route', async () => {
54-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
65+
const { routerStore } = await setup({
66+
title: 'Static title',
67+
});
5568

56-
await expect(firstValueFrom(store.currentRoute$)).resolves.toEqual({
69+
await expect(firstValueFrom(routerStore.currentRoute$)).resolves.toEqual({
70+
children: [],
71+
data: {
72+
testData: 'test-data',
73+
},
74+
fragment: 'test-fragment',
75+
outlet: 'primary',
5776
params: {
5877
id: 'etyDDwAAQBAJ',
5978
},
60-
data: {
61-
testData: 'test-data',
79+
queryParams: {
80+
ref: 'ngrx.io',
81+
},
82+
routeConfig: {
83+
path: ':id',
84+
title: 'Static title',
6285
},
86+
title: 'Static title',
6387
url: [
6488
{
6589
path: 'etyDDwAAQBAJ',
6690
parameters: {},
6791
},
6892
],
69-
outlet: 'primary',
70-
routeConfig: {
71-
path: ':id',
72-
},
73-
queryParams: {
74-
ref: 'ngrx.io',
75-
},
76-
fragment: 'test-fragment',
77-
children: [],
7893
});
7994
});
8095

8196
it('exposes a selector for the fragment', async () => {
82-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
97+
const { routerStore } = await setup();
8398

84-
await expect(firstValueFrom(store.fragment$)).resolves.toBe(
99+
await expect(firstValueFrom(routerStore.fragment$)).resolves.toBe(
85100
'test-fragment'
86101
);
87102
});
88103

89104
it('exposes a selector for query params', async () => {
90-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
105+
const { routerStore } = await setup();
91106

92-
await expect(firstValueFrom(store.queryParams$)).resolves.toEqual({
107+
await expect(firstValueFrom(routerStore.queryParams$)).resolves.toEqual({
93108
ref: 'ngrx.io',
94109
});
95110
});
96111

97112
it('creates a selector for a specific query param', async () => {
98-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
113+
const { routerStore } = await setup();
99114

100-
await expect(firstValueFrom(store.selectQueryParam('ref'))).resolves.toBe(
101-
'ngrx.io'
102-
);
115+
await expect(
116+
firstValueFrom(routerStore.selectQueryParam('ref'))
117+
).resolves.toBe('ngrx.io');
103118
});
104119

105120
it('exposes a selector for route params', async () => {
106-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
121+
const { routerStore } = await setup();
107122

108-
await expect(firstValueFrom(store.routeParams$)).resolves.toEqual({
123+
await expect(firstValueFrom(routerStore.routeParams$)).resolves.toEqual({
109124
id: 'etyDDwAAQBAJ',
110125
});
111126
});
112127

113128
it('creates a selector for a specific route param', async () => {
114-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
129+
const { routerStore } = await setup();
115130

116-
await expect(firstValueFrom(store.selectRouteParam('id'))).resolves.toBe(
117-
'etyDDwAAQBAJ'
118-
);
131+
await expect(
132+
firstValueFrom(routerStore.selectRouteParam('id'))
133+
).resolves.toBe('etyDDwAAQBAJ');
119134
});
120135

121136
it('exposes a selector for route data', async () => {
122-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
137+
const { routerStore } = await setup();
123138

124-
await expect(firstValueFrom(store.routeData$)).resolves.toEqual({
139+
await expect(firstValueFrom(routerStore.routeData$)).resolves.toEqual({
125140
testData: 'test-data',
126141
});
127142
});
128143

129144
it('creates a selector for specific route data', async () => {
130-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
145+
const { routerStore } = await setup();
131146

132147
await expect(
133-
firstValueFrom(store.selectRouteData<string>('testData'))
148+
firstValueFrom(routerStore.selectRouteData<string>('testData'))
134149
).resolves.toBe('test-data');
135150
});
136151

137152
it('exposes a selector for the URL', async () => {
138-
await router.navigateByUrl('/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment');
153+
const { routerStore } = await setup();
139154

140-
await expect(firstValueFrom(store.url$)).resolves.toBe(
155+
await expect(firstValueFrom(routerStore.url$)).resolves.toBe(
141156
'/login/etyDDwAAQBAJ?ref=ngrx.io#test-fragment'
142157
);
143158
});
159+
160+
it('exposes a selector for the route title that emits static route titles', async () => {
161+
const { routerStore } = await setup({
162+
title: 'Static title',
163+
});
164+
165+
await expect(firstValueFrom(routerStore.title$)).resolves.toBe(
166+
'Static title'
167+
);
168+
});
169+
170+
it('exposes a selector for the route title that emits resolved route titles', async () => {
171+
const { routerStore } = await setup({
172+
title: (route) => route.data['testData'],
173+
});
174+
175+
await expect(firstValueFrom(routerStore.title$)).resolves.toBe('test-data');
176+
});
144177
});

packages/router-component-store/src/lib/global-router-store/global-router-store.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,31 +26,37 @@ export class GlobalRouterStore
2626
(routerState) => routerState.root
2727
);
2828

29-
readonly currentRoute$: Observable<MinimalActivatedRouteSnapshot> =
30-
this.select(this.#rootRoute$, (route) => {
29+
currentRoute$: Observable<MinimalActivatedRouteSnapshot> = this.select(
30+
this.#rootRoute$,
31+
(route) => {
3132
while (route.firstChild) {
3233
route = route.firstChild;
3334
}
3435

3536
return route;
36-
});
37-
readonly fragment$: Observable<string | null> = this.select(
37+
}
38+
);
39+
fragment$: Observable<string | null> = this.select(
3840
this.#rootRoute$,
3941
(route) => route.fragment
4042
);
41-
readonly queryParams$: Observable<Params> = this.select(
43+
queryParams$: Observable<Params> = this.select(
4244
this.#rootRoute$,
4345
(route) => route.queryParams
4446
);
45-
readonly routeData$: Observable<Data> = this.select(
47+
routeData$: Observable<Data> = this.select(
4648
this.currentRoute$,
4749
(route) => route.data
4850
);
49-
readonly routeParams$: Observable<Params> = this.select(
51+
routeParams$: Observable<Params> = this.select(
5052
this.currentRoute$,
5153
(route) => route.params
5254
);
53-
readonly url$: Observable<string> = this.select(
55+
title$: Observable<string | undefined> = this.select(
56+
this.currentRoute$,
57+
(route) => route.title
58+
);
59+
url$: Observable<string> = this.select(
5460
this.#routerState$,
5561
(routerState) => routerState.url
5662
);

0 commit comments

Comments
 (0)