Skip to content

Commit 8bbe6dc

Browse files
committed
feat(common): Add Location strategies to manage trailing slash on write
Adds dedicated `LocationStrategy` subclasses: `NoTrailingSlashPathLocationStrategy` and `TrailingSlashPathLocationStrategy`. The `TrailingSlashPathLocationStrategy` ensures that URLs prepared for the browser always end with a slash, while `NoTrailingSlashPathLocationStrategy` ensures they never do. This configuration only affects the URL written to the browser history; the `Location` service continues to normalize paths by stripping trailing slashes when reading from the browser. Example: ```typescript providers: [ {provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy} ] ``` This approach to the trailing slash problem isolates the changes to the existing LocationStrategy abstraction without changes to Router, as was attempted in two other options (#66452 and #66423). From an architectural perspective, this is the cleanest approach for several reasons: 1. Separation of Concerns and "Router Purity": The Router's primary job is to map a URL structure to an application state (ActivatedRoutes). It shouldn't necessarily be burdened with the formatting nuances of the underlying platform unless those nuances affect the state itself. By pushing trailing slash handling to the LocationStrategy, you treat the trailing slash as a "platform serialization format" rather than a "router state" concern. This avoids the "weirdness" in #66423 where the UrlTree (serialization format) disagrees with the ActivatedRouteSnapshot (logical state). 2. Tree Shakability: If an application doesn't care about trailing slashes (which is the default "never" behavior), they don't pay the cost for that logic. It essentially becomes a swappable "driver" for the URL interaction. 3. Simplicity for the Router: #66452 (consuming the slash as a segment) bleeds into the matching logic, potentially causing issues with child routes or wildcards effectively "eating" a segment that should be invisible. This option leaves the matching logic purely focused on meaningful path segments by continuing to strip the trailing slash on read. 4. Consistency with Existing Patterns: Angular already uses LocationStrategy to handle Hash vs Path routing. Adding "Trailing Slash" nuances there is a natural extension of that pattern—it's just another variation of "how do we represent this logic in the browser's address bar?" fixes #16051
1 parent 6fb39d9 commit 8bbe6dc

File tree

5 files changed

+277
-1
lines changed

5 files changed

+277
-1
lines changed

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,33 @@ 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+
By default, the `Location` service strips trailing slashes from URLs on read.
133+
134+
You can configure the `Location` service to force a trailing slash on all URLs written to the browser by providing the `TrailingSlashPathLocationStrategy` in your application.
135+
136+
```ts
137+
import {LocationStrategy, TrailingSlashPathLocationStrategy} from '@angular/common';
138+
139+
bootstrapApplication(App, {
140+
providers: [{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy}],
141+
});
142+
```
143+
144+
You can also force the `Location` service to never have a trailing slash on all URLs written to the browser by providing the `NoTrailingSlashPathLocationStrategy` in your application.
145+
146+
```ts
147+
import {LocationStrategy, NoTrailingSlashPathLocationStrategy} from '@angular/common';
148+
149+
bootstrapApplication(App, {
150+
providers: [{provide: LocationStrategy, useClass: NoTrailingSlashPathLocationStrategy}],
151+
});
152+
```
153+
154+
These strategies only affect the URL written to the browser.
155+
`Location.path()` and `Location.normalize()` will continue to strip trailing slashes when reading the URL.
156+
130157
Angular Router exposes four main areas for customization:
131158

132159
<docs-pill-row>

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,16 @@ export class NgTemplateOutlet<C = unknown> implements OnChanges {
738738
static ɵfac: i0.ɵɵFactoryDeclaration<NgTemplateOutlet<any>, never>;
739739
}
740740

741+
// @public
742+
export class NoTrailingSlashPathLocationStrategy extends PathLocationStrategy {
743+
// (undocumented)
744+
prepareExternalUrl(internal: string): string;
745+
// (undocumented)
746+
static ɵfac: i0.ɵɵFactoryDeclaration<NoTrailingSlashPathLocationStrategy, never>;
747+
// (undocumented)
748+
static ɵprov: i0.ɵɵInjectableDeclaration<NoTrailingSlashPathLocationStrategy>;
749+
}
750+
741751
// @public @deprecated
742752
export enum NumberFormatStyle {
743753
// (undocumented)
@@ -986,6 +996,16 @@ export class TitleCasePipe implements PipeTransform {
986996
static ɵpipe: i0.ɵɵPipeDeclaration<TitleCasePipe, "titlecase", true>;
987997
}
988998

999+
// @public
1000+
export class TrailingSlashPathLocationStrategy extends PathLocationStrategy {
1001+
// (undocumented)
1002+
prepareExternalUrl(internal: string): string;
1003+
// (undocumented)
1004+
static ɵfac: i0.ɵɵFactoryDeclaration<TrailingSlashPathLocationStrategy, never>;
1005+
// (undocumented)
1006+
static ɵprov: i0.ɵɵInjectableDeclaration<TrailingSlashPathLocationStrategy>;
1007+
}
1008+
9891009
// @public @deprecated
9901010
export enum TranslationWidth {
9911011
Abbreviated = 1,

packages/common/src/location/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88

99
export {HashLocationStrategy} from './hash_location_strategy';
1010
export {Location, PopStateEvent} from './location';
11-
export {APP_BASE_HREF, LocationStrategy, PathLocationStrategy} from './location_strategy';
11+
export {
12+
APP_BASE_HREF,
13+
LocationStrategy,
14+
NoTrailingSlashPathLocationStrategy,
15+
PathLocationStrategy,
16+
TrailingSlashPathLocationStrategy,
17+
} from './location_strategy';
1218
export {
1319
BrowserPlatformLocation,
1420
LOCATION_INITIALIZED,

packages/common/src/location/location_strategy.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export const APP_BASE_HREF = new InjectionToken<string>(
102102
* the fragment in the `<base href>` will be preserved, as outlined
103103
* by the [RFC](https://tools.ietf.org/html/rfc3986#section-5.2.2).
104104
*
105+
* To ensure that trailing slashes are always present or never present in the URL, use
106+
* {@link TrailingSlashPathLocationStrategy} or {@link NoTrailingSlashPathLocationStrategy}.
107+
*
105108
* @usageNotes
106109
*
107110
* ### Example
@@ -183,3 +186,45 @@ export class PathLocationStrategy extends LocationStrategy implements OnDestroy
183186
this._platformLocation.historyGo?.(relativePosition);
184187
}
185188
}
189+
190+
/**
191+
* A `LocationStrategy` that ensures URLs never have a trailing slash.
192+
* This strategy only affects the URL written to the browser.
193+
* `Location.path()` and `Location.normalize()` will continue to strip trailing slashes when reading the URL.
194+
*
195+
* @publicApi
196+
*/
197+
@Injectable({providedIn: 'root'})
198+
export class NoTrailingSlashPathLocationStrategy extends PathLocationStrategy {
199+
override prepareExternalUrl(internal: string): string {
200+
const path = extractUrlPath(internal);
201+
if (path.endsWith('/') && path.length > 1) {
202+
internal = path.slice(0, -1) + internal.slice(path.length);
203+
}
204+
return super.prepareExternalUrl(internal);
205+
}
206+
}
207+
208+
/**
209+
* A `LocationStrategy` that ensures URLs always have a trailing slash.
210+
* This strategy only affects the URL written to the browser.
211+
* `Location.path()` and `Location.normalize()` will continue to strip trailing slashes when reading the URL.
212+
*
213+
* @publicApi
214+
*/
215+
@Injectable({providedIn: 'root'})
216+
export class TrailingSlashPathLocationStrategy extends PathLocationStrategy {
217+
override prepareExternalUrl(internal: string): string {
218+
const path = extractUrlPath(internal);
219+
if (!path.endsWith('/')) {
220+
internal = path + '/' + internal.slice(path.length);
221+
}
222+
return super.prepareExternalUrl(internal);
223+
}
224+
}
225+
226+
function extractUrlPath(url: string): string {
227+
const questionMarkOrHashIndex = url.search(/[?#]/);
228+
const pathEnd = questionMarkOrHashIndex > -1 ? questionMarkOrHashIndex : url.length;
229+
return url.slice(0, pathEnd);
230+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
LocationStrategy,
11+
NoTrailingSlashPathLocationStrategy,
12+
PlatformLocation,
13+
TrailingSlashPathLocationStrategy,
14+
} from '@angular/common';
15+
import {Component} from '@angular/core';
16+
import {TestBed} from '@angular/core/testing';
17+
import {provideRouter, RouterLink} from '@angular/router';
18+
import {RouterTestingHarness} from '@angular/router/testing';
19+
20+
@Component({
21+
template: 'home',
22+
standalone: true,
23+
})
24+
class HomeCmp {}
25+
26+
@Component({
27+
template: 'child',
28+
standalone: true,
29+
})
30+
class ChildCmp {}
31+
32+
describe('Trailing Slash Integration', () => {
33+
describe('NoTrailingSlashPathLocationStrategy', () => {
34+
it('should strip trailing slashes and match full path', async () => {
35+
TestBed.configureTestingModule({
36+
providers: [
37+
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
38+
{provide: LocationStrategy, useClass: NoTrailingSlashPathLocationStrategy},
39+
],
40+
});
41+
42+
const harness = await RouterTestingHarness.create();
43+
const location = TestBed.inject(PlatformLocation);
44+
45+
// Navigate to /a
46+
await harness.navigateByUrl('/a');
47+
expect(location.pathname).toBe('/a');
48+
expect(harness.routeNativeElement?.textContent).toBe('home');
49+
});
50+
});
51+
52+
describe('TrailingSlashPathLocationStrategy', () => {
53+
it('should add trailing slashes and match full path', async () => {
54+
TestBed.configureTestingModule({
55+
providers: [
56+
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
57+
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
58+
],
59+
});
60+
61+
const harness = await RouterTestingHarness.create();
62+
const location = TestBed.inject(PlatformLocation);
63+
64+
// Navigate to /a
65+
await harness.navigateByUrl('/a');
66+
expect(location.pathname).toBe('/a/');
67+
expect(harness.routeNativeElement?.textContent).toBe('home');
68+
});
69+
70+
it('should handle root path', async () => {
71+
TestBed.configureTestingModule({
72+
providers: [
73+
provideRouter([{path: '', pathMatch: 'full', component: HomeCmp}]),
74+
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
75+
],
76+
});
77+
78+
const harness = await RouterTestingHarness.create();
79+
const location = TestBed.inject(PlatformLocation);
80+
81+
await harness.navigateByUrl('/');
82+
expect(location.pathname).toBe('/');
83+
expect(harness.routeNativeElement?.textContent).toBe('home');
84+
});
85+
86+
it('should generate correct href in RouterLink', async () => {
87+
@Component({
88+
template: '<a routerLink="/a">link</a>',
89+
imports: [RouterLink],
90+
standalone: true,
91+
})
92+
class LinkCmp {}
93+
94+
TestBed.configureTestingModule({
95+
providers: [
96+
provideRouter([
97+
{path: 'a', pathMatch: 'full', component: HomeCmp},
98+
{path: 'link', component: LinkCmp},
99+
]),
100+
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
101+
],
102+
});
103+
104+
const harness = await RouterTestingHarness.create();
105+
await harness.navigateByUrl('/link');
106+
const link = harness.fixture.nativeElement.querySelector('a');
107+
expect(link.getAttribute('href')).toBe('/a/');
108+
});
109+
110+
it('should handle query params with trailing slash', async () => {
111+
TestBed.configureTestingModule({
112+
providers: [
113+
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
114+
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
115+
],
116+
});
117+
118+
const harness = await RouterTestingHarness.create();
119+
const location = TestBed.inject(PlatformLocation);
120+
121+
await harness.navigateByUrl('/a?q=val');
122+
expect(location.pathname).toBe('/a/');
123+
expect(location.search).toBe('?q=val');
124+
});
125+
126+
it('should handle hash with trailing slash', async () => {
127+
TestBed.configureTestingModule({
128+
providers: [
129+
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
130+
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
131+
],
132+
});
133+
134+
const harness = await RouterTestingHarness.create();
135+
const location = TestBed.inject(PlatformLocation);
136+
137+
await harness.navigateByUrl('/a#frag');
138+
expect(location.pathname).toBe('/a/');
139+
expect(location.hash).toBe('#frag');
140+
});
141+
142+
it('should handle both query params and hash with trailing slash', async () => {
143+
TestBed.configureTestingModule({
144+
providers: [
145+
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
146+
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
147+
],
148+
});
149+
150+
const harness = await RouterTestingHarness.create();
151+
const location = TestBed.inject(PlatformLocation);
152+
153+
await harness.navigateByUrl('/a?q=val#frag');
154+
expect(location.pathname).toBe('/a/');
155+
expect(location.search).toBe('?q=val');
156+
expect(location.hash).toBe('#frag');
157+
});
158+
});
159+
160+
it('should handle auxiliary routes with trailing slash', async () => {
161+
TestBed.configureTestingModule({
162+
providers: [
163+
provideRouter([
164+
{path: 'a', component: HomeCmp},
165+
{path: 'b', outlet: 'aux', component: ChildCmp},
166+
]),
167+
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
168+
],
169+
});
170+
171+
const harness = await RouterTestingHarness.create();
172+
const location = TestBed.inject(PlatformLocation);
173+
174+
// /a(aux:b)
175+
await harness.navigateByUrl('/a(aux:b)');
176+
expect(location.pathname).toBe('/a(aux:b)/');
177+
});
178+
});

0 commit comments

Comments
 (0)