Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions adev/src/content/guide/routing/customizing-route-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,33 @@ provideRouter(routes, withRouterConfig({defaultQueryParamsHandling: 'merge'}));

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

### Configure trailing slash handling

By default, the `Location` service strips trailing slashes from URLs on read.

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.

```ts
import {LocationStrategy, TrailingSlashPathLocationStrategy} from '@angular/common';

bootstrapApplication(App, {
providers: [{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy}],
});
```

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.

```ts
import {LocationStrategy, NoTrailingSlashPathLocationStrategy} from '@angular/common';

bootstrapApplication(App, {
providers: [{provide: LocationStrategy, useClass: NoTrailingSlashPathLocationStrategy}],
});
```

These strategies only affect the URL written to the browser.
`Location.path()` and `Location.normalize()` will continue to strip trailing slashes when reading the URL.

Angular Router exposes four main areas for customization:

<docs-pill-row>
Expand Down
20 changes: 20 additions & 0 deletions goldens/public-api/common/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,16 @@ export class NgTemplateOutlet<C = unknown> implements OnChanges {
static ɵfac: i0.ɵɵFactoryDeclaration<NgTemplateOutlet<any>, never>;
}

// @public
export class NoTrailingSlashPathLocationStrategy extends PathLocationStrategy {
// (undocumented)
prepareExternalUrl(internal: string): string;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<NoTrailingSlashPathLocationStrategy, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<NoTrailingSlashPathLocationStrategy>;
}

// @public @deprecated
export enum NumberFormatStyle {
// (undocumented)
Expand Down Expand Up @@ -986,6 +996,16 @@ export class TitleCasePipe implements PipeTransform {
static ɵpipe: i0.ɵɵPipeDeclaration<TitleCasePipe, "titlecase", true>;
}

// @public
export class TrailingSlashPathLocationStrategy extends PathLocationStrategy {
// (undocumented)
prepareExternalUrl(internal: string): string;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<TrailingSlashPathLocationStrategy, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<TrailingSlashPathLocationStrategy>;
}

// @public @deprecated
export enum TranslationWidth {
Abbreviated = 1,
Expand Down
8 changes: 7 additions & 1 deletion packages/common/src/location/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@

export {HashLocationStrategy} from './hash_location_strategy';
export {Location, PopStateEvent} from './location';
export {APP_BASE_HREF, LocationStrategy, PathLocationStrategy} from './location_strategy';
export {
APP_BASE_HREF,
LocationStrategy,
NoTrailingSlashPathLocationStrategy,
PathLocationStrategy,
TrailingSlashPathLocationStrategy,
} from './location_strategy';
export {
BrowserPlatformLocation,
LOCATION_INITIALIZED,
Expand Down
45 changes: 45 additions & 0 deletions packages/common/src/location/location_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const APP_BASE_HREF = new InjectionToken<string>(
* the fragment in the `<base href>` will be preserved, as outlined
* by the [RFC](https://tools.ietf.org/html/rfc3986#section-5.2.2).
*
* To ensure that trailing slashes are always present or never present in the URL, use
* {@link TrailingSlashPathLocationStrategy} or {@link NoTrailingSlashPathLocationStrategy}.
*
* @usageNotes
*
* ### Example
Expand Down Expand Up @@ -183,3 +186,45 @@ export class PathLocationStrategy extends LocationStrategy implements OnDestroy
this._platformLocation.historyGo?.(relativePosition);
}
}

/**
* A `LocationStrategy` that ensures URLs never have a trailing slash.
* This strategy only affects the URL written to the browser.
* `Location.path()` and `Location.normalize()` will continue to strip trailing slashes when reading the URL.
*
* @publicApi
*/
@Injectable({providedIn: 'root'})
export class NoTrailingSlashPathLocationStrategy extends PathLocationStrategy {
override prepareExternalUrl(internal: string): string {
const path = extractUrlPath(internal);
if (path.endsWith('/') && path.length > 1) {
internal = path.slice(0, -1) + internal.slice(path.length);
}
return super.prepareExternalUrl(internal);
}
}

/**
* A `LocationStrategy` that ensures URLs always have a trailing slash.
* This strategy only affects the URL written to the browser.
* `Location.path()` and `Location.normalize()` will continue to strip trailing slashes when reading the URL.
*
* @publicApi
*/
@Injectable({providedIn: 'root'})
export class TrailingSlashPathLocationStrategy extends PathLocationStrategy {
override prepareExternalUrl(internal: string): string {
const path = extractUrlPath(internal);
if (!path.endsWith('/')) {
internal = path + '/' + internal.slice(path.length);
}
return super.prepareExternalUrl(internal);
}
}

function extractUrlPath(url: string): string {
const questionMarkOrHashIndex = url.search(/[?#]/);
const pathEnd = questionMarkOrHashIndex > -1 ? questionMarkOrHashIndex : url.length;
return url.slice(0, pathEnd);
}
14 changes: 6 additions & 8 deletions packages/platform-browser/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,22 @@ export interface BootstrapContext {
* guide](guide/components/importing).
*
* @usageNotes
* The root component passed into this function *must* be a standalone one (should have the
* `standalone: true` flag in the `@Component` decorator config).
* The root component passed into this function **must** be a standalone one
*
* ```angular-ts
* @Component({
* standalone: true,
* template: 'Hello world!'
* })
* class RootComponent {}
* class Root {}
*
* const appRef: ApplicationRef = await bootstrapApplication(RootComponent);
* const appRef: ApplicationRef = await bootstrapApplication(Root);
* ```
*
* You can add the list of providers that should be available in the application injector by
* specifying the `providers` field in an object passed as the second argument:
*
* ```ts
* await bootstrapApplication(RootComponent, {
* await bootstrapApplication(Root, {
* providers: [
* {provide: BACKEND_URL, useValue: 'https://yourdomain.com/api'}
* ]
Expand All @@ -96,7 +94,7 @@ export interface BootstrapContext {
* existing NgModule (and transitively from all NgModules that it imports):
*
* ```ts
* await bootstrapApplication(RootComponent, {
* await bootstrapApplication(Root, {
* providers: [
* importProvidersFrom(SomeNgModule)
* ]
Expand All @@ -111,7 +109,7 @@ export interface BootstrapContext {
* ```ts
* import {provideProtractorTestingSupport} from '@angular/platform-browser';
*
* await bootstrapApplication(RootComponent, {providers: [provideProtractorTestingSupport()]});
* await bootstrapApplication(Root, {providers: [provideProtractorTestingSupport()]});
* ```
*
* @param rootComponent A reference to a standalone component that should be rendered.
Expand Down
178 changes: 178 additions & 0 deletions packages/router/test/trailing_slash_integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* @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.dev/license
*/

import {
LocationStrategy,
NoTrailingSlashPathLocationStrategy,
PlatformLocation,
TrailingSlashPathLocationStrategy,
} from '@angular/common';
import {Component} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {provideRouter, RouterLink} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';

@Component({
template: 'home',
standalone: true,
})
class HomeCmp {}

@Component({
template: 'child',
standalone: true,
})
class ChildCmp {}

describe('Trailing Slash Integration', () => {
describe('NoTrailingSlashPathLocationStrategy', () => {
it('should strip trailing slashes and match full path', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
{provide: LocationStrategy, useClass: NoTrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
const location = TestBed.inject(PlatformLocation);

// Navigate to /a
await harness.navigateByUrl('/a');
expect(location.pathname).toBe('/a');
expect(harness.routeNativeElement?.textContent).toBe('home');
});
});

describe('TrailingSlashPathLocationStrategy', () => {
it('should add trailing slashes and match full path', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
const location = TestBed.inject(PlatformLocation);

// Navigate to /a
await harness.navigateByUrl('/a');
expect(location.pathname).toBe('/a/');
expect(harness.routeNativeElement?.textContent).toBe('home');
});

it('should handle root path', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: '', pathMatch: 'full', component: HomeCmp}]),
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
const location = TestBed.inject(PlatformLocation);

await harness.navigateByUrl('/');
expect(location.pathname).toBe('/');
expect(harness.routeNativeElement?.textContent).toBe('home');
});

it('should generate correct href in RouterLink', async () => {
@Component({
template: '<a routerLink="/a">link</a>',
imports: [RouterLink],
standalone: true,
})
class LinkCmp {}

TestBed.configureTestingModule({
providers: [
provideRouter([
{path: 'a', pathMatch: 'full', component: HomeCmp},
{path: 'link', component: LinkCmp},
]),
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/link');
const link = harness.fixture.nativeElement.querySelector('a');
expect(link.getAttribute('href')).toBe('/a/');
});

it('should handle query params with trailing slash', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
const location = TestBed.inject(PlatformLocation);

await harness.navigateByUrl('/a?q=val');
expect(location.pathname).toBe('/a/');
expect(location.search).toBe('?q=val');
});

it('should handle hash with trailing slash', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
const location = TestBed.inject(PlatformLocation);

await harness.navigateByUrl('/a#frag');
expect(location.pathname).toBe('/a/');
expect(location.hash).toBe('#frag');
});

it('should handle both query params and hash with trailing slash', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: 'a', pathMatch: 'full', component: HomeCmp}]),
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
const location = TestBed.inject(PlatformLocation);

await harness.navigateByUrl('/a?q=val#frag');
expect(location.pathname).toBe('/a/');
expect(location.search).toBe('?q=val');
expect(location.hash).toBe('#frag');
});
});

it('should handle auxiliary routes with trailing slash', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([
{path: 'a', component: HomeCmp},
{path: 'b', outlet: 'aux', component: ChildCmp},
]),
{provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy},
],
});

const harness = await RouterTestingHarness.create();
const location = TestBed.inject(PlatformLocation);

// /a(aux:b)
await harness.navigateByUrl('/a(aux:b)');
expect(location.pathname).toBe('/a(aux:b)/');
});
});
Loading
Loading