Skip to content

Commit 12fccc5

Browse files
authored
feat(router): add trailingSlash config option
This commit introduces a highly requested `trailingSlash` configuration option to the Angular Router, allowing developers to control how trailing slashes are handled in their applications. The options are: - 'always': Enforces a trailing slash on all URLs. - 'never': Removes trailing slashes from all URLs (default). - 'preserve': Respects the presence or absence of a trailing slash as defined in the UrlTree.
1 parent d100e69 commit 12fccc5

15 files changed

Lines changed: 442 additions & 21 deletions

File tree

adev/src/content/guide/routing/customizing-route-behavior.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ provideRouter(routes, withRouterConfig({defaultQueryParamsHandling: 'merge'}));
127127

128128
This is especially helpful for search and filter pages to automatically retain existing filters when additional parameters are provided.
129129

130+
### Configure trailing slash handling
131+
132+
`trailingSlash` configures how the router and location service handle trailing slashes in URLs.
133+
134+
- `'always'`: Forces a trailing slash on all URLs.
135+
- `'never'`: Removes trailing slashes from all URLs.
136+
- `'preserve'`: Keeps the trailing slash if present, and omits it if not.
137+
138+
NOTE: By default, the `DefaultUrlSerializer` preserves trailing slashes, but `Location.path()` and `Location.normalize()` strip them.
139+
140+
```ts
141+
provideRouter(routes, withRouterConfig({trailingSlash: 'preserve'}));
142+
```
143+
130144
Angular Router exposes four main areas for customization:
131145

132146
<docs-pill-row>
@@ -224,7 +238,7 @@ When implementing a custom `RouteReuseStrategy`, you may need to manually destro
224238
Since `DetachedRouteHandle` is an opaque type, you cannot call a destroy method directly on it. Instead, use the `destroyDetachedRouteHandle` function provided by the Router.
225239

226240
```ts
227-
import { destroyDetachedRouteHandle } from '@angular/router';
241+
import {destroyDetachedRouteHandle} from '@angular/router';
228242

229243
// ... inside your strategy
230244
if (this.handles.size > MAX_CACHE_SIZE) {
@@ -245,12 +259,10 @@ By default, Angular does not destroy the injectors of detached routes, even if t
245259
To enable automatic cleanup of unused route injectors, you can use the `withExperimentalAutoCleanupInjectors` feature in your router configuration. This feature checks which routes are currently stored by the strategy after navigations and destroys the injectors of any detached routes that are not currently stored by the reuse strategy.
246260

247261
```ts
248-
import { provideRouter, withExperimentalAutoCleanupInjectors } from '@angular/router';
262+
import {provideRouter, withExperimentalAutoCleanupInjectors} from '@angular/router';
249263

250264
export const appConfig: ApplicationConfig = {
251-
providers: [
252-
provideRouter(routes, withExperimentalAutoCleanupInjectors())
253-
]
265+
providers: [provideRouter(routes, withExperimentalAutoCleanupInjectors())],
254266
};
255267
```
256268

goldens/public-api/common/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,9 @@ export function provideNetlifyLoader(path?: string): Provider[];
948948
// @public
949949
export function registerLocaleData(data: any, localeId?: string | any, extraData?: any): void;
950950

951+
// @public
952+
export const REMOVE_TRAILING_SLASH: InjectionToken<boolean>;
953+
951954
// @public
952955
export class SlicePipe implements PipeTransform {
953956
// (undocumented)

goldens/public-api/router/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export function defaultUrlMatcher(segments: UrlSegment[], segmentGroup: UrlSegme
238238

239239
// @public
240240
export class DefaultUrlSerializer implements UrlSerializer {
241+
constructor();
241242
parse(url: string): UrlTree;
242243
serialize(tree: UrlTree): string;
243244
}
@@ -764,6 +765,7 @@ export interface RouterConfigOptions {
764765
onSameUrlNavigation?: OnSameUrlNavigation;
765766
paramsInheritanceStrategy?: 'emptyOnly' | 'always';
766767
resolveNavigationPromiseOnError?: boolean;
768+
trailingSlash?: 'always' | 'never' | 'preserve';
767769
urlUpdateStrategy?: 'deferred' | 'eager';
768770
}
769771

packages/common/src/location/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
export {HashLocationStrategy} from './hash_location_strategy';
10-
export {Location, PopStateEvent} from './location';
10+
export {Location, PopStateEvent, REMOVE_TRAILING_SLASH} from './location';
1111
export {APP_BASE_HREF, LocationStrategy, PathLocationStrategy} from './location_strategy';
1212
export {
1313
BrowserPlatformLocation,

packages/common/src/location/location.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Injectable, OnDestroy, ɵɵinject} from '@angular/core';
9+
import {Injectable, InjectionToken, OnDestroy, inject, ɵɵinject} from '@angular/core';
1010
import {Subject, SubscriptionLike} from 'rxjs';
1111

1212
import {LocationStrategy} from './location_strategy';
@@ -20,6 +20,22 @@ export interface PopStateEvent {
2020
url?: string;
2121
}
2222

23+
/**
24+
* A token that can be provided to configure whether the `Location` service should
25+
* strip trailing slashes from URLs.
26+
*
27+
* If `true`, the `Location` service will remove trailing slashes from URLs.
28+
* If `false`, the `Location` service will not remove trailing slashes from URLs.
29+
*
30+
* @publicApi
31+
*/
32+
export const REMOVE_TRAILING_SLASH = new InjectionToken<boolean>(
33+
typeof ngDevMode === 'undefined' || ngDevMode ? 'remove trailing slash' : '',
34+
{
35+
factory: () => true,
36+
},
37+
);
38+
2339
/**
2440
* @description
2541
*
@@ -35,23 +51,22 @@ export interface PopStateEvent {
3551
* routing.
3652
*
3753
* `Location` is responsible for normalizing the URL against the application's base href.
38-
* A normalized URL is absolute from the URL host, includes the application's base href, and has no
39-
* trailing slash:
54+
* A normalized URL is absolute from the URL host, includes the application's base href, and may
55+
* have a trailing slash:
4056
* - `/my/app/user/123` is normalized
4157
* - `my/app/user/123` **is not** normalized
42-
* - `/my/app/user/123/` **is not** normalized
58+
* - `/my/app/user/123/` **is** normalized if `REMOVE_TRAILING_SLASH` is `false`
4359
*
4460
* ### Example
4561
*
4662
* {@example common/location/ts/path_location_component.ts region='LocationComponent'}
4763
*
64+
* @see {@link LocationStrategy}
65+
* @see [Routing and Navigation Guide](guide/routing/common-router-tasks)
66+
*
4867
* @publicApi
4968
*/
50-
@Injectable({
51-
providedIn: 'root',
52-
// See #23917
53-
useFactory: createLocation,
54-
})
69+
@Injectable({providedIn: 'root', useFactory: createLocation})
5570
export class Location implements OnDestroy {
5671
/** @internal */
5772
_subject = new Subject<PopStateEvent>();
@@ -63,9 +78,17 @@ export class Location implements OnDestroy {
6378
_urlChangeListeners: ((url: string, state: unknown) => void)[] = [];
6479
/** @internal */
6580
_urlChangeSubscription: SubscriptionLike | null = null;
81+
/** @internal */
82+
readonly _stripTrailingSlash: boolean;
6683

6784
constructor(locationStrategy: LocationStrategy) {
6885
this._locationStrategy = locationStrategy;
86+
try {
87+
this._stripTrailingSlash = inject(REMOVE_TRAILING_SLASH, {optional: true}) ?? true;
88+
} catch {
89+
// failsafe against not calling constructor in injection context
90+
this._stripTrailingSlash = true;
91+
}
6992
const baseHref = this._locationStrategy.getBaseHref();
7093
// Note: This class's interaction with base HREF does not fully follow the rules
7194
// outlined in the spec https://www.freesoft.org/CIE/RFC/1808/18.htm.
@@ -132,7 +155,8 @@ export class Location implements OnDestroy {
132155
* @returns The normalized URL string.
133156
*/
134157
normalize(url: string): string {
135-
return Location.stripTrailingSlash(_stripBasePath(this._basePath, _stripIndexHtml(url)));
158+
const s = _stripBasePath(this._basePath, _stripIndexHtml(url));
159+
return this._stripTrailingSlash ? Location.stripTrailingSlash(s) : s;
136160
}
137161

138162
/**

packages/common/test/location/location_spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
LocationStrategy,
1414
PathLocationStrategy,
1515
PlatformLocation,
16+
REMOVE_TRAILING_SLASH,
1617
} from '../../index';
1718
import {MockLocationStrategy, MockPlatformLocation} from '../../testing';
1819
import {TestBed} from '@angular/core/testing';
@@ -305,4 +306,18 @@ describe('Location Class', () => {
305306
expect(location.normalize(baseHref + path)).toBe(path);
306307
});
307308
});
309+
describe('Location with REMOVE_TRAILING_SLASH', () => {
310+
it('should strip trailing slash by default', () => {
311+
const location = TestBed.inject(Location);
312+
expect(location.normalize('/a/b/')).toBe('/a/b');
313+
});
314+
315+
it('should NOT strip trailing slash when REMOVE_TRAILING_SLASH is false', () => {
316+
TestBed.configureTestingModule({
317+
providers: [{provide: REMOVE_TRAILING_SLASH, useValue: false}],
318+
});
319+
const location = TestBed.inject(Location);
320+
expect(location.normalize('/a/b/')).toBe('/a/b/');
321+
});
322+
});
308323
});

packages/common/testing/src/location_mock.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export class SpyLocation implements Location {
3535
_urlChangeListeners: ((url: string, state: unknown) => void)[] = [];
3636
/** @internal */
3737
_urlChangeSubscription: SubscriptionLike | null = null;
38+
/** @internal */
39+
_stripTrailingSlash = true;
3840

3941
/** @docs-private */
4042
ngOnDestroy(): void {

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@
236236
"REACTIVE_TEMPLATE_CONSUMER",
237237
"REMOVE_STYLES_ON_COMPONENT_DESTROY",
238238
"REMOVE_STYLES_ON_COMPONENT_DESTROY_DEFAULT",
239+
"REMOVE_TRAILING_SLASH",
239240
"RENDERER",
240241
"REQUIRED_UNSET_VALUE",
241242
"ROUTER_CONFIGURATION",

packages/router/src/provide_router.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
LocationStrategy,
1313
ViewportScroller,
1414
Location,
15+
REMOVE_TRAILING_SLASH,
1516
ɵNavigationAdapterForLocation,
1617
} from '@angular/common';
1718
import {
@@ -675,7 +676,16 @@ export type RouterConfigurationFeature =
675676
* @publicApi
676677
*/
677678
export function withRouterConfig(options: RouterConfigOptions): RouterConfigurationFeature {
678-
const providers = [{provide: ROUTER_CONFIGURATION, useValue: options}];
679+
const providers = [
680+
{provide: ROUTER_CONFIGURATION, useValue: options},
681+
{
682+
provide: REMOVE_TRAILING_SLASH,
683+
useFactory: () => {
684+
const {trailingSlash} = options;
685+
return trailingSlash === 'always' || trailingSlash === 'preserve' ? false : true;
686+
},
687+
},
688+
];
679689
return routerFeature(RouterFeatureKind.RouterConfigurationFeature, providers);
680690
}
681691

packages/router/src/router_config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ export interface RouterConfigOptions {
110110
*/
111111
urlUpdateStrategy?: 'deferred' | 'eager';
112112

113+
/**
114+
* Configures how the `DefaultUrlSerializer` and `Location` service handle trailing slashes in URLs.
115+
*
116+
* - 'always': Forces a trailing slash on all URLs.
117+
* - 'never': Removes trailing slashes from all URLs.
118+
* - 'preserve': Keeps the trailing slash if present, and omits it if not.
119+
*
120+
* Note: By default, the `DefaultUrlSerializer` preserves trailing slashes, but `Location.path()`
121+
* and `Location.normalize()` strip them.
122+
*/
123+
trailingSlash?: 'always' | 'never' | 'preserve';
124+
113125
/**
114126
* The default strategy to use for handling query params in `Router.createUrlTree` when one is not provided.
115127
*

0 commit comments

Comments
 (0)