From 95dc502053ac4d9b6e6d95a70d8b5b7c1b80effd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:20:53 +0000 Subject: [PATCH 1/8] Initial plan From b6338b722c22af6645da64efcb38ad7b43004a00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:48:27 +0000 Subject: [PATCH 2/8] Complete @ngworker/router-store package implementation Co-authored-by: LayZeeDK <6364586+LayZeeDK@users.noreply.github.com> --- .gitignore | 2 + knip.json | 3 +- package.json | 2 + packages/router-store/.eslintrc.json | 43 +++++++ packages/router-store/README.md | 7 ++ packages/router-store/jest.config.ts | 22 ++++ packages/router-store/ng-package.json | 7 ++ packages/router-store/package.json | 43 +++++++ packages/router-store/project.json | 37 ++++++ packages/router-store/src/index.ts | 11 ++ .../minimal-activated-route-state-snapshot.ts | 89 +++++++++++++ .../minimal-router-state-snapshot.ts | 30 +++++ .../@ngrx/router-store/minimal_serializer.ts | 83 +++++++++++++ .../global-router-store.ts | 87 +++++++++++++ .../provide-global-router-store.ts | 43 +++++++ .../lib/global-router-store/selectors.spec.ts | 117 ++++++++++++++++++ .../src/lib/internal-strict-query-params.ts | 10 ++ .../src/lib/internal-strict-route-data.ts | 11 ++ .../src/lib/internal-strict-route-params.ts | 10 ++ .../src/lib/is-navigation-event.ts | 23 ++++ .../local-router-store/local-router-store.ts | 108 ++++++++++++++++ .../provide-local-router-store.ts | 41 ++++++ packages/router-store/src/lib/router-store.ts | 94 ++++++++++++++ .../src/lib/strict-query-params.ts | 11 ++ .../router-store/src/lib/strict-route-data.ts | 10 ++ .../src/lib/strict-route-params.ts | 7 ++ .../src/lib/util-types/omit-symbol-index.ts | 16 +++ .../src/lib/util-types/strict-no-any.ts | 19 +++ packages/router-store/src/test-setup.ts | 8 ++ packages/router-store/tsconfig.json | 29 +++++ packages/router-store/tsconfig.lib.json | 17 +++ packages/router-store/tsconfig.lib.prod.json | 9 ++ packages/router-store/tsconfig.spec.json | 16 +++ tsconfig.base.json | 3 +- yarn.lock | 14 +++ 35 files changed, 1080 insertions(+), 2 deletions(-) create mode 100644 packages/router-store/.eslintrc.json create mode 100644 packages/router-store/README.md create mode 100644 packages/router-store/jest.config.ts create mode 100644 packages/router-store/ng-package.json create mode 100644 packages/router-store/package.json create mode 100644 packages/router-store/project.json create mode 100644 packages/router-store/src/index.ts create mode 100644 packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts create mode 100644 packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts create mode 100644 packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts create mode 100644 packages/router-store/src/lib/global-router-store/global-router-store.ts create mode 100644 packages/router-store/src/lib/global-router-store/provide-global-router-store.ts create mode 100644 packages/router-store/src/lib/global-router-store/selectors.spec.ts create mode 100644 packages/router-store/src/lib/internal-strict-query-params.ts create mode 100644 packages/router-store/src/lib/internal-strict-route-data.ts create mode 100644 packages/router-store/src/lib/internal-strict-route-params.ts create mode 100644 packages/router-store/src/lib/is-navigation-event.ts create mode 100644 packages/router-store/src/lib/local-router-store/local-router-store.ts create mode 100644 packages/router-store/src/lib/local-router-store/provide-local-router-store.ts create mode 100644 packages/router-store/src/lib/router-store.ts create mode 100644 packages/router-store/src/lib/strict-query-params.ts create mode 100644 packages/router-store/src/lib/strict-route-data.ts create mode 100644 packages/router-store/src/lib/strict-route-params.ts create mode 100644 packages/router-store/src/lib/util-types/omit-symbol-index.ts create mode 100644 packages/router-store/src/lib/util-types/strict-no-any.ts create mode 100644 packages/router-store/src/test-setup.ts create mode 100644 packages/router-store/tsconfig.json create mode 100644 packages/router-store/tsconfig.lib.json create mode 100644 packages/router-store/tsconfig.lib.prod.json create mode 100644 packages/router-store/tsconfig.spec.json diff --git a/.gitignore b/.gitignore index e7c782ae..a7e53896 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ Thumbs.db .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +.angular diff --git a/knip.json b/knip.json index b3023f23..b5068a6e 100644 --- a/knip.json +++ b/knip.json @@ -3,12 +3,13 @@ "angular": { "config": ["project.json"] }, - "include": ["nsExports", "nsTypes", "classMembers"], + "include": ["nsExports", "nsTypes"], "ignoreDependencies": [ "@angular-eslint/eslint-plugin", "@angular-eslint/template-parser", "@angular/cli", "@angular/compiler", + "@angular/forms", "@angular/language-service", "@ngrx/component-store", "@ngrx/signals", diff --git a/package.json b/package.json index 4b496b34..39d82f21 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "yarn": "^1.22.22" }, "dependencies": { + "@angular/animations": "17.0.9", "@angular/common": "17.0.9", "@angular/compiler": "17.0.9", "@angular/core": "17.0.9", + "@angular/forms": "17.0.9", "@angular/platform-browser": "17.0.9", "@angular/platform-browser-dynamic": "17.0.9", "@angular/router": "17.0.9", diff --git a/packages/router-store/.eslintrc.json b/packages/router-store/.eslintrc.json new file mode 100644 index 00000000..8cc77421 --- /dev/null +++ b/packages/router-store/.eslintrc.json @@ -0,0 +1,43 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "ngworker", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "ngworker", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/packages/router-store/README.md b/packages/router-store/README.md new file mode 100644 index 00000000..7b7f7a9d --- /dev/null +++ b/packages/router-store/README.md @@ -0,0 +1,7 @@ +# router-store + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test router-store` to execute the unit tests. diff --git a/packages/router-store/jest.config.ts b/packages/router-store/jest.config.ts new file mode 100644 index 00000000..7c6daf2a --- /dev/null +++ b/packages/router-store/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'router-store', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/packages/router-store', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/packages/router-store/ng-package.json b/packages/router-store/ng-package.json new file mode 100644 index 00000000..ec4042a0 --- /dev/null +++ b/packages/router-store/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/packages/router-store", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/packages/router-store/package.json b/packages/router-store/package.json new file mode 100644 index 00000000..a43003a1 --- /dev/null +++ b/packages/router-store/package.json @@ -0,0 +1,43 @@ +{ + "name": "@ngworker/router-store", + "version": "17.0.0", + "description": "A strictly typed lightweight alternative to NgRx Router Store and ActivatedRoute using Angular Signals", + "license": "MIT", + "sideEffects": false, + "peerDependencies": { + "@angular/core": "^17.0.0", + "@angular/router": "^17.0.0" + }, + "dependencies": {}, + "keywords": [ + "angular", + "router", + "signals", + "ngworker" + ], + "author": { + "name": "Lars Gyrup Brink Nielsen", + "email": "larsbrinknielsen@gmail.com", + "url": "https://github.com/LayZeeDK" + }, + "contributors": [], + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/LayZeeDK" + } + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/", + "tag": "unknown" + }, + "bugs": { + "url": "https://github.com/ngworker/router-component-store/issue" + }, + "homepage": "https://github.com/ngworker/router-component-store#readme", + "repository": { + "type": "git", + "url": "https://github.com/ngworker/router-component-store" + } +} diff --git a/packages/router-store/project.json b/packages/router-store/project.json new file mode 100644 index 00000000..ae9ec3bf --- /dev/null +++ b/packages/router-store/project.json @@ -0,0 +1,37 @@ +{ + "name": "router-store", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/router-store/src", + "prefix": "ngworker", + "tags": [], + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "packages/router-store/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "packages/router-store/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "packages/router-store/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/router-store/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/packages/router-store/src/index.ts b/packages/router-store/src/index.ts new file mode 100644 index 00000000..f4f202a9 --- /dev/null +++ b/packages/router-store/src/index.ts @@ -0,0 +1,11 @@ +// GlobalRouterStore +export * from './lib/global-router-store/provide-global-router-store'; +// Serializable route state +export * from './lib/@ngrx/router-store/minimal-activated-route-state-snapshot'; +// LocalRouterStore +export * from './lib/local-router-store/provide-local-router-store'; +// RouterStore +export * from './lib/router-store'; +export * from './lib/strict-query-params'; +export * from './lib/strict-route-data'; +export * from './lib/strict-route-params'; diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts new file mode 100644 index 00000000..ad86e713 --- /dev/null +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts @@ -0,0 +1,89 @@ +/** + * @license + * The MIT License (MIT) + * + * Copyright (c) 2017 Brandon Roberts, Mike Ryan, Victor Savkin, Rob Wormald + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { ActivatedRouteSnapshot } from '@angular/router'; +import { StrictQueryParams } from '../../strict-query-params'; +import { StrictRouteData } from '../../strict-route-data'; +import { StrictRouteParams } from '../../strict-route-params'; + +/** + * Contains the information about a route associated with a component loaded in + * an outlet at a particular moment in time. MinimalActivatedRouteSnapshot can + * also be used to traverse the router state tree. + */ +export interface MinimalActivatedRouteSnapshot { + /** + * The configuration used to match this route. + */ + readonly routeConfig: ActivatedRouteSnapshot['routeConfig']; + /** + * The URL segments matched by this route. + */ + readonly url: ActivatedRouteSnapshot['url']; + /** + * The matrix parameters scoped to this route. + */ + readonly params: StrictRouteParams; + /** + * The query parameters shared by all the routes. + */ + readonly queryParams: StrictQueryParams; + /** + * The URL fragment shared by all the routes. + */ + readonly fragment: ActivatedRouteSnapshot['fragment']; + /** + * The static and resolved data of this route. + * + * @remarks + * Contains serializable route `Data` without its symbol index, in particular + * without the `Symbol.for(RouteTitle)` key as this is an internal value for + * the Angular `Router`. Instead, we access the resolved route title through + * `MinimalActivatedRouteSnapshot['title']`. + */ + readonly data: StrictRouteData; + /** + * The outlet name of the route. + */ + readonly outlet: ActivatedRouteSnapshot['outlet']; + /** + * The resolved route title. + */ + readonly title: ActivatedRouteSnapshot['title']; + /** + * The first child of this route in the router state tree + */ + readonly firstChild?: MinimalActivatedRouteSnapshot; + /** + * The children of this route in the router state tree. + */ + readonly children: MinimalActivatedRouteSnapshot[]; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts new file mode 100644 index 00000000..da093563 --- /dev/null +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts @@ -0,0 +1,30 @@ +/** + * @license + * The MIT License (MIT) + * + * Copyright (c) 2017 Brandon Roberts, Mike Ryan, Victor Savkin, Rob Wormald + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { MinimalActivatedRouteSnapshot } from './minimal-activated-route-state-snapshot'; + +export interface MinimalRouterStateSnapshot { + readonly root: MinimalActivatedRouteSnapshot; + readonly url: string; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts new file mode 100644 index 00000000..2d59912f --- /dev/null +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts @@ -0,0 +1,83 @@ +/** + * @license + * The MIT License (MIT) + * + * Copyright (c) 2017 Brandon Roberts, Mike Ryan, Victor Savkin, Rob Wormald + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + Data, + RouterStateSnapshot, +} from '@angular/router'; +import { InternalStrictRouteData } from '../../internal-strict-route-data'; +import { InternalStrictRouteParams } from '../../internal-strict-route-params'; +import { MinimalActivatedRouteSnapshot } from './minimal-activated-route-state-snapshot'; +import { MinimalRouterStateSnapshot } from './minimal-router-state-snapshot'; + +@Injectable({ + providedIn: 'root', +}) +export class MinimalRouterStateSerializer { + serialize(routerState: RouterStateSnapshot): MinimalRouterStateSnapshot { + return { + root: this.#serializeRouteSnapshot(routerState.root), + url: routerState.url, + }; + } + + #serializeRouteData(routeData: Data): InternalStrictRouteData { + return Object.fromEntries(Object.entries(routeData)); + } + + #serializeRouteSnapshot( + routeSnapshot: ActivatedRouteSnapshot + ): MinimalActivatedRouteSnapshot { + const children = routeSnapshot.children.map((childRouteSnapshot) => + this.#serializeRouteSnapshot(childRouteSnapshot) + ); + return { + params: routeSnapshot.params as InternalStrictRouteParams, + data: this.#serializeRouteData( + routeSnapshot.data + ) as InternalStrictRouteData, + url: routeSnapshot.url, + outlet: routeSnapshot.outlet, + title: routeSnapshot.title, + routeConfig: routeSnapshot.routeConfig + ? { + path: routeSnapshot.routeConfig.path, + pathMatch: routeSnapshot.routeConfig.pathMatch, + redirectTo: routeSnapshot.routeConfig.redirectTo, + outlet: routeSnapshot.routeConfig.outlet, + title: + typeof routeSnapshot.routeConfig.title === 'string' + ? routeSnapshot.routeConfig.title + : undefined, + } + : null, + queryParams: routeSnapshot.queryParams as InternalStrictRouteParams, + fragment: routeSnapshot.fragment, + firstChild: children[0], + children, + }; + } +} \ No newline at end of file diff --git a/packages/router-store/src/lib/global-router-store/global-router-store.ts b/packages/router-store/src/lib/global-router-store/global-router-store.ts new file mode 100644 index 00000000..b69f9784 --- /dev/null +++ b/packages/router-store/src/lib/global-router-store/global-router-store.ts @@ -0,0 +1,87 @@ +import { DestroyRef, Injectable, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { MinimalRouterStateSnapshot } from '../@ngrx/router-store/minimal-router-state-snapshot'; +import { MinimalRouterStateSerializer } from '../@ngrx/router-store/minimal_serializer'; +import { InternalStrictQueryParams } from '../internal-strict-query-params'; +import { InternalStrictRouteData } from '../internal-strict-route-data'; +import { InternalStrictRouteParams } from '../internal-strict-route-params'; +import { isNavigationEvent } from '../is-navigation-event'; +import { RouterStore } from '../router-store'; +import { StrictQueryParams } from '../strict-query-params'; +import { StrictRouteData } from '../strict-route-data'; +import { StrictRouteParams } from '../strict-route-params'; + +@Injectable({ + providedIn: 'root', +}) +export class GlobalRouterStore extends RouterStore { + readonly #router = inject(Router); + readonly #serializer = inject(MinimalRouterStateSerializer); + readonly #destroyRef = inject(DestroyRef); + + readonly #routerStateSignal: WritableSignal = signal( + this.#serializer.serialize(this.#router.routerState.snapshot) + ); + + readonly #rootRoute = computed( + (): MinimalActivatedRouteSnapshot => this.#routerStateSignal().root + ); + + readonly currentRoute = computed((): MinimalActivatedRouteSnapshot => { + let route = this.#rootRoute(); + + while (route.firstChild) { + route = route.firstChild; + } + + return route; + }); + + readonly fragment = computed((): string | null => this.#rootRoute().fragment); + + readonly queryParams = computed( + (): StrictQueryParams => this.#rootRoute().queryParams + ); + + readonly routeData = computed( + (): StrictRouteData => this.currentRoute().data + ); + + readonly routeParams = computed( + (): StrictRouteParams => this.currentRoute().params + ); + + readonly title = computed( + (): string | undefined => this.currentRoute().title + ); + + readonly url = computed((): string => this.#routerStateSignal().url); + + constructor() { + super(); + + // Listen to router events to update state + this.#router.events.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((routerEvent) => { + if (isNavigationEvent(routerEvent)) { + const routerState = this.#serializer.serialize(this.#router.routerState.snapshot); + this.#routerStateSignal.set(routerState); + } + }); + } + + selectQueryParam( + param: string + ): Signal { + return computed(() => (this.queryParams() as InternalStrictQueryParams)[param]); + } + + selectRouteDataParam(key: string): Signal { + return computed(() => (this.routeData() as InternalStrictRouteData)[key]); + } + + selectRouteParam(param: string): Signal { + return computed(() => (this.routeParams() as InternalStrictRouteParams)[param]); + } +} \ No newline at end of file diff --git a/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts b/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts new file mode 100644 index 00000000..e1ddd879 --- /dev/null +++ b/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts @@ -0,0 +1,43 @@ +import { ClassProvider, Provider } from '@angular/core'; +import { MinimalRouterStateSerializer } from '../@ngrx/router-store/minimal_serializer'; +import { RouterStore } from '../router-store'; +import { GlobalRouterStore } from './global-router-store'; + +/** + * Provide an application-wide router store that can be injected in any class. + * + * Use this provider factory in a root environment injector. + * + * @returns The providers required for a global router store. + * + * @example + * // Providing in a standalone Angular application + * // main.ts + * // (...) + * import { provideGlobalRouterStore } from '@ngworker/router-store'; + * + * bootstrapApplication(AppComponent, { + * providers: [provideGlobalRouterStore()], + * }).catch((error) => console.error(error)); + * + * + * @example + * // Providing in a classic Angular application + * // app.module.ts + * // (...) + * import { provideGlobalRouterStore } from '@ngworker/router-store'; + * + * (@)NgModule({ + * // (...) + * providers: [provideGlobalRouterStore()], + * }) + * export class AppModule {} + */ +export function provideGlobalRouterStore(): Provider[] { + const globalRouterStoreProvider: ClassProvider = { + provide: RouterStore, + useClass: GlobalRouterStore, + }; + + return [MinimalRouterStateSerializer, globalRouterStoreProvider]; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/global-router-store/selectors.spec.ts b/packages/router-store/src/lib/global-router-store/selectors.spec.ts new file mode 100644 index 00000000..c3950638 --- /dev/null +++ b/packages/router-store/src/lib/global-router-store/selectors.spec.ts @@ -0,0 +1,117 @@ +import { Component } from '@angular/core'; +import { Route } from '@angular/router'; +import { createFeatureHarness } from '@ngworker/spectacular'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { RouterStore } from '../router-store'; +import { StrictRouteData } from '../strict-route-data'; +import { GlobalRouterStore } from './global-router-store'; +import { provideGlobalRouterStore } from './provide-global-router-store'; + +@Component({ + standalone: true, + template: '', +}) +class DummyAuthComponent {} + +describe(`${GlobalRouterStore.name} selectors`, () => { + function setup({ + data = {}, + title, + }: { + readonly data?: Route['data']; + readonly title?: Route['title']; + } = {}) { + const harness = createFeatureHarness({ + featurePath: '', + providers: [provideGlobalRouterStore()], + routes: [ + { + path: ':token', + component: DummyAuthComponent, + data, + title, + }, + ], + }); + + return { harness }; + } + + it('exposes a selector for the current route', async () => { + const { harness } = setup({ + data: { + testData: 'test-data', + }, + title: 'Static title', + }); + + await harness.router.navigateByUrl('/bqbNGrezShfz?ref=ngworker.github.io#test-fragment'); + + const expectedRouteSnapshot: Partial = { + children: [], + data: { + testData: 'test-data', + }, + fragment: 'test-fragment', + outlet: 'primary', + params: { + token: 'bqbNGrezShfz', + }, + queryParams: { + ref: 'ngworker.github.io', + }, + title: 'Static title', + }; + + const currentRoute = harness.inject(RouterStore).currentRoute(); + expect(currentRoute).toEqual( + expect.objectContaining(expectedRouteSnapshot) + ); + }); + + it('exposes a selector for route data', async () => { + const expectedRouteData: StrictRouteData = { + testData: 'test-data', + }; + const { harness } = setup({ + data: expectedRouteData, + }); + + await harness.router.navigateByUrl('/VDhyGSDTYfvz?ref=ngworker.github.io#test-fragment'); + + const routeData = harness.inject(RouterStore).routeData(); + expect(routeData).toEqual(expectedRouteData); + }); + + it('creates a selector for specific route data', async () => { + const expectedTestData = 'test-data'; + const { harness } = setup({ + data: { + testData: expectedTestData, + }, + }); + + await harness.router.navigateByUrl('/SFUXQFSDgMyw?ref=ngworker.github.io#test-fragment'); + + const testData = harness.inject(RouterStore).selectRouteDataParam('testData')(); + expect(testData).toBe(expectedTestData); + }); + + it('creates a selector for a specific query parameter', async () => { + const { harness } = setup(); + + await harness.router.navigateByUrl('/vOaURFhUDkYN?ref=ngworker.github.io#test-fragment'); + + const ref = harness.inject(RouterStore).selectQueryParam('ref')(); + expect(ref).toBe('ngworker.github.io'); + }); + + it('creates a selector for a specific route parameter', async () => { + const { harness } = setup(); + + await harness.router.navigateByUrl('/token?ref=ngworker.github.io#test-fragment'); + + const token = harness.inject(RouterStore).selectRouteParam('token')(); + expect(token).toBe('token'); + }); +}); \ No newline at end of file diff --git a/packages/router-store/src/lib/internal-strict-query-params.ts b/packages/router-store/src/lib/internal-strict-query-params.ts new file mode 100644 index 00000000..7e643554 --- /dev/null +++ b/packages/router-store/src/lib/internal-strict-query-params.ts @@ -0,0 +1,10 @@ +import { Params } from '@angular/router'; +import { StrictNoAny } from './util-types/strict-no-any'; + +/** + * @remarks We use this type to ensure compatibility with {@link Params}. + * @internal + */ +export type InternalStrictQueryParams = Readonly< + StrictNoAny +>; \ No newline at end of file diff --git a/packages/router-store/src/lib/internal-strict-route-data.ts b/packages/router-store/src/lib/internal-strict-route-data.ts new file mode 100644 index 00000000..8eb11186 --- /dev/null +++ b/packages/router-store/src/lib/internal-strict-route-data.ts @@ -0,0 +1,11 @@ +import { Data } from '@angular/router'; +import { OmitSymbolIndex } from './util-types/omit-symbol-index'; +import { StrictNoAny } from './util-types/strict-no-any'; + +/** + * @remarks We use this type to ensure compatibility with {@link Data}. + * @internal + */ +export type InternalStrictRouteData = Readonly< + StrictNoAny> +>; \ No newline at end of file diff --git a/packages/router-store/src/lib/internal-strict-route-params.ts b/packages/router-store/src/lib/internal-strict-route-params.ts new file mode 100644 index 00000000..be4c4937 --- /dev/null +++ b/packages/router-store/src/lib/internal-strict-route-params.ts @@ -0,0 +1,10 @@ +import { Params } from '@angular/router'; +import { StrictNoAny } from './util-types/strict-no-any'; + +/** + * @remarks We use this type to ensure compatibility with {@link Params}. + * @internal + */ +export type InternalStrictRouteParams = Readonly< + StrictNoAny +>; \ No newline at end of file diff --git a/packages/router-store/src/lib/is-navigation-event.ts b/packages/router-store/src/lib/is-navigation-event.ts new file mode 100644 index 00000000..65cd5f53 --- /dev/null +++ b/packages/router-store/src/lib/is-navigation-event.ts @@ -0,0 +1,23 @@ +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Event as RouterEvent, + RoutesRecognized, +} from '@angular/router'; + +/** + * @returns `true` when the specified Angular Router event is navigation event + * that causes `RouterStore` to synchronize its internal state. + * @returns `false` otherwise + */ +export function isNavigationEvent(routerEvent: RouterEvent): boolean { + return [ + NavigationStart, + RoutesRecognized, + NavigationEnd, + NavigationCancel, + NavigationError, + ].some((navigationEventType) => routerEvent instanceof navigationEventType); +} \ No newline at end of file diff --git a/packages/router-store/src/lib/local-router-store/local-router-store.ts b/packages/router-store/src/lib/local-router-store/local-router-store.ts new file mode 100644 index 00000000..d0b46a35 --- /dev/null +++ b/packages/router-store/src/lib/local-router-store/local-router-store.ts @@ -0,0 +1,108 @@ +import { DestroyRef, Injectable, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + Router, + RouterStateSnapshot, + createUrlTreeFromSnapshot, +} from '@angular/router'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { MinimalRouterStateSnapshot } from '../@ngrx/router-store/minimal-router-state-snapshot'; +import { MinimalRouterStateSerializer } from '../@ngrx/router-store/minimal_serializer'; +import { InternalStrictQueryParams } from '../internal-strict-query-params'; +import { InternalStrictRouteData } from '../internal-strict-route-data'; +import { InternalStrictRouteParams } from '../internal-strict-route-params'; +import { isNavigationEvent } from '../is-navigation-event'; +import { RouterStore } from '../router-store'; +import { StrictQueryParams } from '../strict-query-params'; +import { StrictRouteData } from '../strict-route-data'; +import { StrictRouteParams } from '../strict-route-params'; + +function createRouterStateSnapshot( + route: ActivatedRoute, + router: Router +): RouterStateSnapshot { + return { + root: route.snapshot, + url: router.serializeUrl( + createUrlTreeFromSnapshot( + route.snapshot, + [], + route.snapshot.queryParams, + route.snapshot.fragment + ) + ), + }; +} + +function serializeRouterState( + route: ActivatedRoute, + router: Router, + serializer: MinimalRouterStateSerializer +): MinimalRouterStateSnapshot { + return serializer.serialize(createRouterStateSnapshot(route, router)); +} + +@Injectable() +export class LocalRouterStore extends RouterStore { + readonly #route = inject(ActivatedRoute); + readonly #router = inject(Router); + readonly #serializer = inject(MinimalRouterStateSerializer); + readonly #destroyRef = inject(DestroyRef); + + readonly #routerStateSignal: WritableSignal = signal( + serializeRouterState(this.#route, this.#router, this.#serializer) + ); + + readonly currentRoute = computed( + (): MinimalActivatedRouteSnapshot => this.#routerStateSignal().root + ); + + readonly fragment: Signal = toSignal(this.#route.fragment, { + requireSync: true, + }); + + readonly queryParams: Signal = toSignal(this.#route.queryParams, { + requireSync: true, + }); + + readonly routeData: Signal = toSignal(this.#route.data, { + requireSync: true, + }); + + readonly routeParams: Signal = toSignal(this.#route.params, { + requireSync: true, + }); + + readonly title: Signal = toSignal(this.#route.title, { + requireSync: true, + }); + + readonly url = computed((): string => this.#routerStateSignal().url); + + constructor() { + super(); + + // Listen to router events to update state + this.#router.events.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((routerEvent) => { + if (isNavigationEvent(routerEvent)) { + const routerState = serializeRouterState(this.#route, this.#router, this.#serializer); + this.#routerStateSignal.set(routerState); + } + }); + } + + selectQueryParam( + param: string + ): Signal { + return computed(() => (this.queryParams() as InternalStrictQueryParams)[param]); + } + + selectRouteDataParam(key: string): Signal { + return computed(() => (this.routeData() as InternalStrictRouteData)[key]); + } + + selectRouteParam(param: string): Signal { + return computed(() => (this.routeParams() as InternalStrictRouteParams)[param]); + } +} \ No newline at end of file diff --git a/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts b/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts new file mode 100644 index 00000000..4e833240 --- /dev/null +++ b/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts @@ -0,0 +1,41 @@ +import { ClassProvider, Provider } from '@angular/core'; +import { RouterStore } from '../router-store'; +import { LocalRouterStore } from './local-router-store'; + +/** + * Provide a component-level router store that can be injected in any directive, + * component, pipe, or component-level service. + * + * Use this provider factory in `Component.providers` or + * `Component.viewProviders` to make a local router store available to a + * component sub-tree. + * + * @returns The providers required for a local router store. + * + * @example + * // Providing and injecting in a component + * // hero-detail.component.ts + * // (...) + * import { + * provideLocalRouterStore, + * RouterStore, + * } from '@ngworker/router-store'; + * + * (@)Component({ + * // (...) + * providers: [provideLocalRouterStore()], + * }) + * export class HeroDetailComponent { + * #routerStore = inject(RouterStore); + * + * heroId = this.#routerStore.selectRouteParam('id'); + * } + */ +export function provideLocalRouterStore(): Provider[] { + const localRouterStoreProvider: ClassProvider = { + provide: RouterStore, + useClass: LocalRouterStore, + }; + + return [localRouterStoreProvider]; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/router-store.ts b/packages/router-store/src/lib/router-store.ts new file mode 100644 index 00000000..e0e12d21 --- /dev/null +++ b/packages/router-store/src/lib/router-store.ts @@ -0,0 +1,94 @@ +import { Signal } from '@angular/core'; +import { MinimalActivatedRouteSnapshot } from './@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { StrictQueryParams } from './strict-query-params'; +import { StrictRouteData } from './strict-route-data'; +import { StrictRouteParams } from './strict-route-params'; + +/** + * An Angular Router-connecting store using Angular Signals. + * + * A `RouterStore` service is provided by using either + * `provideGlobalRouterStore` or `provideLocalRouterStore`. + * + * The _global_ `RouterStore` service is provided in a root environment injector + * and is never destroyed but can be injected in any class. + * + * A _local_ `RouterStore` requires a component-level provider, follows the + * lifecycle of that component, and can be injected in declarables as well as + * other component-level services. + * + * @example + * // Usage in a component + * // hero-detail.component.ts + * // (...) + * import { RouterStore } from '@ngworker/router-store'; + * + * (@)Component({ + * // (...) + * }) + * export class HeroDetailComponent { + * #routerStore = inject(RouterStore); + * + * heroId = this.#routerStore.selectRouteParam('id'); + * } + */ +export abstract class RouterStore { + /** + * Select the current route. + */ + abstract readonly currentRoute: Signal; + /** + * Select the current route fragment. + */ + abstract readonly fragment: Signal; + /** + * Select the current route query parameters. + */ + abstract readonly queryParams: Signal; + /** + * Select the current route data. + */ + abstract readonly routeData: Signal; + /** + * Select the current route parameters. + */ + abstract readonly routeParams: Signal; + /** + * Select the resolved route title. + */ + abstract readonly title: Signal; + /** + * Select the current URL. + */ + abstract readonly url: Signal; + + /** + * Select the specified route data. + * + * @param key The route data key. + * + * @example Usage + * const limit = computed(() => Number(routerStore.selectRouteDataParam('limit')())); + */ + abstract selectRouteDataParam(key: string): Signal; + /** + * Select the specified query parameter. + * + * @param param The name of the query parameter. + * + * @example Usage + * const order = routerStore.selectQueryParam('order'); + */ + abstract selectQueryParam( + param: string + ): Signal; + /** + * Select the specified route parameter. + * + * @param param The name of the route parameter. + * + * @example Usage + * const id = routerStore.selectRouteParam('id'); + */ + abstract selectRouteParam(param: string): Signal; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/strict-query-params.ts b/packages/router-store/src/lib/strict-query-params.ts new file mode 100644 index 00000000..02b1d647 --- /dev/null +++ b/packages/router-store/src/lib/strict-query-params.ts @@ -0,0 +1,11 @@ +// [@typescript-eslint/no-unused-vars] Used in TSDoc. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Params } from '@angular/router'; + +/** + * Strict route query {@link Params} with read-only members where the `any` member + * type is converted to `string | readonly string[] | undefined`. + */ +export interface StrictQueryParams { + readonly [param: string]: string | readonly string[] | undefined; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/strict-route-data.ts b/packages/router-store/src/lib/strict-route-data.ts new file mode 100644 index 00000000..8578dffc --- /dev/null +++ b/packages/router-store/src/lib/strict-route-data.ts @@ -0,0 +1,10 @@ +/** + * Serializable route `Data` without its symbol index, in particular without the + * `Symbol(RouteTitle)` key as this is an internal value for the Angular + * `Router`. + * + * Additionally, the `any` member type is converted to `unknown`. + */ +export interface StrictRouteData { + readonly [key: string]: unknown; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/strict-route-params.ts b/packages/router-store/src/lib/strict-route-params.ts new file mode 100644 index 00000000..60db8f54 --- /dev/null +++ b/packages/router-store/src/lib/strict-route-params.ts @@ -0,0 +1,7 @@ +/** + * Strict route {@link Params} with read-only members where the `any` member + * type is converted to `string | undefined`. + */ +export interface StrictRouteParams { + readonly [param: string]: string | undefined; +} \ No newline at end of file diff --git a/packages/router-store/src/lib/util-types/omit-symbol-index.ts b/packages/router-store/src/lib/util-types/omit-symbol-index.ts new file mode 100644 index 00000000..8f693322 --- /dev/null +++ b/packages/router-store/src/lib/util-types/omit-symbol-index.ts @@ -0,0 +1,16 @@ +/** + * Remove `symbol` index signature from the specified type. + * + * @example Usage + * ``` + * type RouteData = { [key: string | symbol]: any; }; + * type SerializableRouteData = OmitSymbolIndex; + * ``` + * + * `SerializableRouteData` is `{ [key: string]: any }`. + */ +export type OmitSymbolIndex = { + [TShapeKey in keyof TShape as symbol extends TShapeKey + ? never + : TShapeKey]: TShape[TShapeKey]; +}; \ No newline at end of file diff --git a/packages/router-store/src/lib/util-types/strict-no-any.ts b/packages/router-store/src/lib/util-types/strict-no-any.ts new file mode 100644 index 00000000..7a3137f8 --- /dev/null +++ b/packages/router-store/src/lib/util-types/strict-no-any.ts @@ -0,0 +1,19 @@ +/** + * Convert `any` member types to `unknown` or a specified type in the specified + * type. + * + * @example Usage + * ``` + * type RouteData = { [key: string | symbol]: any; }; + * type StrictRouteData = Strict; + * ``` + * + * `StrictRouteData` is `{ [key: string | symbol]: unknown }`. + */ +export type StrictNoAny = { + // [@typescript-eslint/no-explicit-any] We detect `any` to convert it to the specified type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [TShapeKey in keyof TShape]: TShape[TShapeKey] extends any + ? TStrictType + : TShape[TShapeKey]; +}; \ No newline at end of file diff --git a/packages/router-store/src/test-setup.ts b/packages/router-store/src/test-setup.ts new file mode 100644 index 00000000..ab1eeeb3 --- /dev/null +++ b/packages/router-store/src/test-setup.ts @@ -0,0 +1,8 @@ +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +}; +import 'jest-preset-angular/setup-jest'; diff --git a/packages/router-store/tsconfig.json b/packages/router-store/tsconfig.json new file mode 100644 index 00000000..92049739 --- /dev/null +++ b/packages/router-store/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/packages/router-store/tsconfig.lib.json b/packages/router-store/tsconfig.lib.json new file mode 100644 index 00000000..063e5257 --- /dev/null +++ b/packages/router-store/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/router-store/tsconfig.lib.prod.json b/packages/router-store/tsconfig.lib.prod.json new file mode 100644 index 00000000..2a2faa88 --- /dev/null +++ b/packages/router-store/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/packages/router-store/tsconfig.spec.json b/packages/router-store/tsconfig.spec.json new file mode 100644 index 00000000..53fbfcdc --- /dev/null +++ b/packages/router-store/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2b56a80d..9289577f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,7 +20,8 @@ ], "@ngworker/router-signal-store": [ "packages/router-signal-store/src/index.ts" - ] + ], + "@ngworker/router-store": ["packages/router-store/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] diff --git a/yarn.lock b/yarn.lock index 2497a7eb..745298ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -207,6 +207,13 @@ "@angular-eslint/bundled-angular-compiler" "17.0.1" "@typescript-eslint/utils" "6.10.0" +"@angular/animations@17.0.9": + version "17.0.9" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-17.0.9.tgz#0e5a6a47d037a9a75b982fcd25596a55c1b1561e" + integrity sha512-TcAdBQyGqglgbxdiJcaHc7FcDNkzTXGRtZuPOcr4sYmBvryBu2q18edwzo6+QDYFaoGredFhE5RnOIw+M4A3Xw== + dependencies: + tslib "^2.3.0" + "@angular/cli@~17.0.0": version "17.0.10" resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-17.0.10.tgz#24a9cdbdf2d19eb4138c9ccada5eebd4202014b4" @@ -266,6 +273,13 @@ dependencies: tslib "^2.3.0" +"@angular/forms@17.0.9": + version "17.0.9" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-17.0.9.tgz#9edb3298714354197fc9dfaed46c40cdb646d870" + integrity sha512-UCZhJH5FCRPGmYHfKCTGbOXqz4SEs9bVkAQGwhHzhM3Bwn3cZ/LKN2UfOglIcwkqKXxKnRx+VkJ2M1KfZJAvLQ== + dependencies: + tslib "^2.3.0" + "@angular/language-service@17.0.9": version "17.0.9" resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-17.0.9.tgz#3fd8e26378371749dc45ff00ea92e8eca1e74311" From d4ebd2c621dd43b938182a6500aa1362349e4e11 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Wed, 24 Sep 2025 13:54:34 +0200 Subject: [PATCH 3/8] style(store): format --- .../minimal-activated-route-state-snapshot.ts | 2 +- .../minimal-router-state-snapshot.ts | 2 +- .../@ngrx/router-store/minimal_serializer.ts | 2 +- .../global-router-store.ts | 41 ++++++++---- .../provide-global-router-store.ts | 2 +- .../lib/global-router-store/selectors.spec.ts | 26 +++++--- .../src/lib/internal-strict-query-params.ts | 2 +- .../src/lib/internal-strict-route-data.ts | 2 +- .../src/lib/internal-strict-route-params.ts | 2 +- .../src/lib/is-navigation-event.ts | 2 +- .../local-router-store/local-router-store.ts | 63 +++++++++++++------ .../provide-local-router-store.ts | 2 +- packages/router-store/src/lib/router-store.ts | 2 +- .../src/lib/strict-query-params.ts | 2 +- .../router-store/src/lib/strict-route-data.ts | 2 +- .../src/lib/strict-route-params.ts | 2 +- .../src/lib/util-types/omit-symbol-index.ts | 2 +- .../src/lib/util-types/strict-no-any.ts | 2 +- 18 files changed, 105 insertions(+), 55 deletions(-) diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts index ad86e713..99727b97 100644 --- a/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts @@ -86,4 +86,4 @@ export interface MinimalActivatedRouteSnapshot { * The children of this route in the router state tree. */ readonly children: MinimalActivatedRouteSnapshot[]; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts index da093563..5f9864f8 100644 --- a/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts @@ -27,4 +27,4 @@ import { MinimalActivatedRouteSnapshot } from './minimal-activated-route-state-s export interface MinimalRouterStateSnapshot { readonly root: MinimalActivatedRouteSnapshot; readonly url: string; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts index 2d59912f..8f106466 100644 --- a/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts @@ -80,4 +80,4 @@ export class MinimalRouterStateSerializer { children, }; } -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/global-router-store/global-router-store.ts b/packages/router-store/src/lib/global-router-store/global-router-store.ts index b69f9784..e236f8c4 100644 --- a/packages/router-store/src/lib/global-router-store/global-router-store.ts +++ b/packages/router-store/src/lib/global-router-store/global-router-store.ts @@ -1,4 +1,12 @@ -import { DestroyRef, Injectable, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; +import { + DestroyRef, + Injectable, + Signal, + WritableSignal, + computed, + inject, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; @@ -21,9 +29,8 @@ export class GlobalRouterStore extends RouterStore { readonly #serializer = inject(MinimalRouterStateSerializer); readonly #destroyRef = inject(DestroyRef); - readonly #routerStateSignal: WritableSignal = signal( - this.#serializer.serialize(this.#router.routerState.snapshot) - ); + readonly #routerStateSignal: WritableSignal = + signal(this.#serializer.serialize(this.#router.routerState.snapshot)); readonly #rootRoute = computed( (): MinimalActivatedRouteSnapshot => this.#routerStateSignal().root @@ -63,18 +70,24 @@ export class GlobalRouterStore extends RouterStore { super(); // Listen to router events to update state - this.#router.events.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((routerEvent) => { - if (isNavigationEvent(routerEvent)) { - const routerState = this.#serializer.serialize(this.#router.routerState.snapshot); - this.#routerStateSignal.set(routerState); - } - }); + this.#router.events + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((routerEvent) => { + if (isNavigationEvent(routerEvent)) { + const routerState = this.#serializer.serialize( + this.#router.routerState.snapshot + ); + this.#routerStateSignal.set(routerState); + } + }); } selectQueryParam( param: string ): Signal { - return computed(() => (this.queryParams() as InternalStrictQueryParams)[param]); + return computed( + () => (this.queryParams() as InternalStrictQueryParams)[param] + ); } selectRouteDataParam(key: string): Signal { @@ -82,6 +95,8 @@ export class GlobalRouterStore extends RouterStore { } selectRouteParam(param: string): Signal { - return computed(() => (this.routeParams() as InternalStrictRouteParams)[param]); + return computed( + () => (this.routeParams() as InternalStrictRouteParams)[param] + ); } -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts b/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts index e1ddd879..67989446 100644 --- a/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts +++ b/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts @@ -40,4 +40,4 @@ export function provideGlobalRouterStore(): Provider[] { }; return [MinimalRouterStateSerializer, globalRouterStoreProvider]; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/global-router-store/selectors.spec.ts b/packages/router-store/src/lib/global-router-store/selectors.spec.ts index c3950638..96c8fbaf 100644 --- a/packages/router-store/src/lib/global-router-store/selectors.spec.ts +++ b/packages/router-store/src/lib/global-router-store/selectors.spec.ts @@ -45,7 +45,9 @@ describe(`${GlobalRouterStore.name} selectors`, () => { title: 'Static title', }); - await harness.router.navigateByUrl('/bqbNGrezShfz?ref=ngworker.github.io#test-fragment'); + await harness.router.navigateByUrl( + '/bqbNGrezShfz?ref=ngworker.github.io#test-fragment' + ); const expectedRouteSnapshot: Partial = { children: [], @@ -77,7 +79,9 @@ describe(`${GlobalRouterStore.name} selectors`, () => { data: expectedRouteData, }); - await harness.router.navigateByUrl('/VDhyGSDTYfvz?ref=ngworker.github.io#test-fragment'); + await harness.router.navigateByUrl( + '/VDhyGSDTYfvz?ref=ngworker.github.io#test-fragment' + ); const routeData = harness.inject(RouterStore).routeData(); expect(routeData).toEqual(expectedRouteData); @@ -91,16 +95,22 @@ describe(`${GlobalRouterStore.name} selectors`, () => { }, }); - await harness.router.navigateByUrl('/SFUXQFSDgMyw?ref=ngworker.github.io#test-fragment'); + await harness.router.navigateByUrl( + '/SFUXQFSDgMyw?ref=ngworker.github.io#test-fragment' + ); - const testData = harness.inject(RouterStore).selectRouteDataParam('testData')(); + const testData = harness + .inject(RouterStore) + .selectRouteDataParam('testData')(); expect(testData).toBe(expectedTestData); }); it('creates a selector for a specific query parameter', async () => { const { harness } = setup(); - await harness.router.navigateByUrl('/vOaURFhUDkYN?ref=ngworker.github.io#test-fragment'); + await harness.router.navigateByUrl( + '/vOaURFhUDkYN?ref=ngworker.github.io#test-fragment' + ); const ref = harness.inject(RouterStore).selectQueryParam('ref')(); expect(ref).toBe('ngworker.github.io'); @@ -109,9 +119,11 @@ describe(`${GlobalRouterStore.name} selectors`, () => { it('creates a selector for a specific route parameter', async () => { const { harness } = setup(); - await harness.router.navigateByUrl('/token?ref=ngworker.github.io#test-fragment'); + await harness.router.navigateByUrl( + '/token?ref=ngworker.github.io#test-fragment' + ); const token = harness.inject(RouterStore).selectRouteParam('token')(); expect(token).toBe('token'); }); -}); \ No newline at end of file +}); diff --git a/packages/router-store/src/lib/internal-strict-query-params.ts b/packages/router-store/src/lib/internal-strict-query-params.ts index 7e643554..97ad537f 100644 --- a/packages/router-store/src/lib/internal-strict-query-params.ts +++ b/packages/router-store/src/lib/internal-strict-query-params.ts @@ -7,4 +7,4 @@ import { StrictNoAny } from './util-types/strict-no-any'; */ export type InternalStrictQueryParams = Readonly< StrictNoAny ->; \ No newline at end of file +>; diff --git a/packages/router-store/src/lib/internal-strict-route-data.ts b/packages/router-store/src/lib/internal-strict-route-data.ts index 8eb11186..5f7c0986 100644 --- a/packages/router-store/src/lib/internal-strict-route-data.ts +++ b/packages/router-store/src/lib/internal-strict-route-data.ts @@ -8,4 +8,4 @@ import { StrictNoAny } from './util-types/strict-no-any'; */ export type InternalStrictRouteData = Readonly< StrictNoAny> ->; \ No newline at end of file +>; diff --git a/packages/router-store/src/lib/internal-strict-route-params.ts b/packages/router-store/src/lib/internal-strict-route-params.ts index be4c4937..5210fc65 100644 --- a/packages/router-store/src/lib/internal-strict-route-params.ts +++ b/packages/router-store/src/lib/internal-strict-route-params.ts @@ -7,4 +7,4 @@ import { StrictNoAny } from './util-types/strict-no-any'; */ export type InternalStrictRouteParams = Readonly< StrictNoAny ->; \ No newline at end of file +>; diff --git a/packages/router-store/src/lib/is-navigation-event.ts b/packages/router-store/src/lib/is-navigation-event.ts index 65cd5f53..3e3a86e6 100644 --- a/packages/router-store/src/lib/is-navigation-event.ts +++ b/packages/router-store/src/lib/is-navigation-event.ts @@ -20,4 +20,4 @@ export function isNavigationEvent(routerEvent: RouterEvent): boolean { NavigationCancel, NavigationError, ].some((navigationEventType) => routerEvent instanceof navigationEventType); -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/local-router-store/local-router-store.ts b/packages/router-store/src/lib/local-router-store/local-router-store.ts index d0b46a35..1bade8a5 100644 --- a/packages/router-store/src/lib/local-router-store/local-router-store.ts +++ b/packages/router-store/src/lib/local-router-store/local-router-store.ts @@ -1,4 +1,12 @@ -import { DestroyRef, Injectable, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; +import { + DestroyRef, + Injectable, + Signal, + WritableSignal, + computed, + inject, + signal, +} from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, @@ -50,9 +58,8 @@ export class LocalRouterStore extends RouterStore { readonly #serializer = inject(MinimalRouterStateSerializer); readonly #destroyRef = inject(DestroyRef); - readonly #routerStateSignal: WritableSignal = signal( - serializeRouterState(this.#route, this.#router, this.#serializer) - ); + readonly #routerStateSignal: WritableSignal = + signal(serializeRouterState(this.#route, this.#router, this.#serializer)); readonly currentRoute = computed( (): MinimalActivatedRouteSnapshot => this.#routerStateSignal().root @@ -62,19 +69,25 @@ export class LocalRouterStore extends RouterStore { requireSync: true, }); - readonly queryParams: Signal = toSignal(this.#route.queryParams, { - requireSync: true, - }); + readonly queryParams: Signal = toSignal( + this.#route.queryParams, + { + requireSync: true, + } + ); readonly routeData: Signal = toSignal(this.#route.data, { requireSync: true, }); - readonly routeParams: Signal = toSignal(this.#route.params, { - requireSync: true, - }); + readonly routeParams: Signal = toSignal( + this.#route.params, + { + requireSync: true, + } + ); - readonly title: Signal = toSignal(this.#route.title, { + readonly title: Signal = toSignal(this.#route.title, { requireSync: true, }); @@ -84,18 +97,26 @@ export class LocalRouterStore extends RouterStore { super(); // Listen to router events to update state - this.#router.events.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((routerEvent) => { - if (isNavigationEvent(routerEvent)) { - const routerState = serializeRouterState(this.#route, this.#router, this.#serializer); - this.#routerStateSignal.set(routerState); - } - }); + this.#router.events + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((routerEvent) => { + if (isNavigationEvent(routerEvent)) { + const routerState = serializeRouterState( + this.#route, + this.#router, + this.#serializer + ); + this.#routerStateSignal.set(routerState); + } + }); } selectQueryParam( param: string ): Signal { - return computed(() => (this.queryParams() as InternalStrictQueryParams)[param]); + return computed( + () => (this.queryParams() as InternalStrictQueryParams)[param] + ); } selectRouteDataParam(key: string): Signal { @@ -103,6 +124,8 @@ export class LocalRouterStore extends RouterStore { } selectRouteParam(param: string): Signal { - return computed(() => (this.routeParams() as InternalStrictRouteParams)[param]); + return computed( + () => (this.routeParams() as InternalStrictRouteParams)[param] + ); } -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts b/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts index 4e833240..7e9cb8cb 100644 --- a/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts +++ b/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts @@ -38,4 +38,4 @@ export function provideLocalRouterStore(): Provider[] { }; return [localRouterStoreProvider]; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/router-store.ts b/packages/router-store/src/lib/router-store.ts index e0e12d21..f676eff1 100644 --- a/packages/router-store/src/lib/router-store.ts +++ b/packages/router-store/src/lib/router-store.ts @@ -91,4 +91,4 @@ export abstract class RouterStore { * const id = routerStore.selectRouteParam('id'); */ abstract selectRouteParam(param: string): Signal; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/strict-query-params.ts b/packages/router-store/src/lib/strict-query-params.ts index 02b1d647..c4797027 100644 --- a/packages/router-store/src/lib/strict-query-params.ts +++ b/packages/router-store/src/lib/strict-query-params.ts @@ -8,4 +8,4 @@ import { Params } from '@angular/router'; */ export interface StrictQueryParams { readonly [param: string]: string | readonly string[] | undefined; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/strict-route-data.ts b/packages/router-store/src/lib/strict-route-data.ts index 8578dffc..0a1866fc 100644 --- a/packages/router-store/src/lib/strict-route-data.ts +++ b/packages/router-store/src/lib/strict-route-data.ts @@ -7,4 +7,4 @@ */ export interface StrictRouteData { readonly [key: string]: unknown; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/strict-route-params.ts b/packages/router-store/src/lib/strict-route-params.ts index 60db8f54..8a037c5c 100644 --- a/packages/router-store/src/lib/strict-route-params.ts +++ b/packages/router-store/src/lib/strict-route-params.ts @@ -4,4 +4,4 @@ */ export interface StrictRouteParams { readonly [param: string]: string | undefined; -} \ No newline at end of file +} diff --git a/packages/router-store/src/lib/util-types/omit-symbol-index.ts b/packages/router-store/src/lib/util-types/omit-symbol-index.ts index 8f693322..c1f9d70d 100644 --- a/packages/router-store/src/lib/util-types/omit-symbol-index.ts +++ b/packages/router-store/src/lib/util-types/omit-symbol-index.ts @@ -13,4 +13,4 @@ export type OmitSymbolIndex = { [TShapeKey in keyof TShape as symbol extends TShapeKey ? never : TShapeKey]: TShape[TShapeKey]; -}; \ No newline at end of file +}; diff --git a/packages/router-store/src/lib/util-types/strict-no-any.ts b/packages/router-store/src/lib/util-types/strict-no-any.ts index 7a3137f8..6eb074f2 100644 --- a/packages/router-store/src/lib/util-types/strict-no-any.ts +++ b/packages/router-store/src/lib/util-types/strict-no-any.ts @@ -16,4 +16,4 @@ export type StrictNoAny = { [TShapeKey in keyof TShape]: TShape[TShapeKey] extends any ? TStrictType : TShape[TShapeKey]; -}; \ No newline at end of file +}; From 52fca06ad28e18a8e63e9ffc0ffeaf1a341c6e83 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 25 Sep 2025 09:48:19 +0200 Subject: [PATCH 4/8] chore: remove `@angular/animations` and `@angular/forms` --- knip.json | 1 - package.json | 2 -- yarn.lock | 14 -------------- 3 files changed, 17 deletions(-) diff --git a/knip.json b/knip.json index b5068a6e..fd4b5c11 100644 --- a/knip.json +++ b/knip.json @@ -9,7 +9,6 @@ "@angular-eslint/template-parser", "@angular/cli", "@angular/compiler", - "@angular/forms", "@angular/language-service", "@ngrx/component-store", "@ngrx/signals", diff --git a/package.json b/package.json index 39d82f21..4b496b34 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,9 @@ "yarn": "^1.22.22" }, "dependencies": { - "@angular/animations": "17.0.9", "@angular/common": "17.0.9", "@angular/compiler": "17.0.9", "@angular/core": "17.0.9", - "@angular/forms": "17.0.9", "@angular/platform-browser": "17.0.9", "@angular/platform-browser-dynamic": "17.0.9", "@angular/router": "17.0.9", diff --git a/yarn.lock b/yarn.lock index 745298ee..2497a7eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -207,13 +207,6 @@ "@angular-eslint/bundled-angular-compiler" "17.0.1" "@typescript-eslint/utils" "6.10.0" -"@angular/animations@17.0.9": - version "17.0.9" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-17.0.9.tgz#0e5a6a47d037a9a75b982fcd25596a55c1b1561e" - integrity sha512-TcAdBQyGqglgbxdiJcaHc7FcDNkzTXGRtZuPOcr4sYmBvryBu2q18edwzo6+QDYFaoGredFhE5RnOIw+M4A3Xw== - dependencies: - tslib "^2.3.0" - "@angular/cli@~17.0.0": version "17.0.10" resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-17.0.10.tgz#24a9cdbdf2d19eb4138c9ccada5eebd4202014b4" @@ -273,13 +266,6 @@ dependencies: tslib "^2.3.0" -"@angular/forms@17.0.9": - version "17.0.9" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-17.0.9.tgz#9edb3298714354197fc9dfaed46c40cdb646d870" - integrity sha512-UCZhJH5FCRPGmYHfKCTGbOXqz4SEs9bVkAQGwhHzhM3Bwn3cZ/LKN2UfOglIcwkqKXxKnRx+VkJ2M1KfZJAvLQ== - dependencies: - tslib "^2.3.0" - "@angular/language-service@17.0.9": version "17.0.9" resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-17.0.9.tgz#3fd8e26378371749dc45ff00ea92e8eca1e74311" From 2848958105ca1dcff57df5b55f961ea44b57704d Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 25 Sep 2025 09:50:23 +0200 Subject: [PATCH 5/8] chore(knip): analyze class members --- knip.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.json b/knip.json index fd4b5c11..b3023f23 100644 --- a/knip.json +++ b/knip.json @@ -3,7 +3,7 @@ "angular": { "config": ["project.json"] }, - "include": ["nsExports", "nsTypes"], + "include": ["nsExports", "nsTypes", "classMembers"], "ignoreDependencies": [ "@angular-eslint/eslint-plugin", "@angular-eslint/template-parser", From 2ec47f8083f2caa53cc52674516f96e0df466d8d Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 25 Sep 2025 09:57:06 +0200 Subject: [PATCH 6/8] chore(git): remove duplicate ignore pattern --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index a7e53896..2dcf86ca 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ node_modules/ !.vscode/extensions.json # misc -.angular/ +.angular .nx/cache/ .nx/workspace-data/ /.sass-cache @@ -43,5 +43,3 @@ Thumbs.db .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md - -.angular From af4f6e39874ec7acf9e1031e58d604c367d0de82 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 25 Sep 2025 09:59:00 +0200 Subject: [PATCH 7/8] chore(store): configure ESLint --- packages/router-store/.eslintrc.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/router-store/.eslintrc.json b/packages/router-store/.eslintrc.json index 8cc77421..b8d18fc9 100644 --- a/packages/router-store/.eslintrc.json +++ b/packages/router-store/.eslintrc.json @@ -4,6 +4,9 @@ "overrides": [ { "files": ["*.ts"], + "parserOptions": { + "project": ["packages/router-store/tsconfig.*?.json"] + }, "extends": [ "plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates" @@ -13,7 +16,7 @@ "error", { "type": "attribute", - "prefix": "ngworker", + "prefix": "ngw", "style": "camelCase" } ], @@ -21,7 +24,7 @@ "error", { "type": "element", - "prefix": "ngworker", + "prefix": "ngw", "style": "kebab-case" } ] From 482ecc846171e4ad21f79926fab1ddfb119f55c2 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 25 Sep 2025 10:09:26 +0200 Subject: [PATCH 8/8] style: remove blank line --- packages/router-store/src/lib/router-store.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/router-store/src/lib/router-store.ts b/packages/router-store/src/lib/router-store.ts index f676eff1..1a7b1089 100644 --- a/packages/router-store/src/lib/router-store.ts +++ b/packages/router-store/src/lib/router-store.ts @@ -61,7 +61,6 @@ export abstract class RouterStore { * Select the current URL. */ abstract readonly url: Signal; - /** * Select the specified route data. *