Skip to content

feat(router): refine trailing slash handling with hybrid ephemeral/append strategy#66423

Closed
atscott wants to merge 1 commit intoangular:mainfrom
atscott:trailingslashbackup
Closed

feat(router): refine trailing slash handling with hybrid ephemeral/append strategy#66423
atscott wants to merge 1 commit intoangular:mainfrom
atscott:trailingslashbackup

Conversation

@atscott
Copy link
Copy Markdown
Contributor

@atscott atscott commented Jan 8, 2026

This commit introduces configuration to the Angular Router ('always' | 'never'), resolving long-standing inconsistencies between matching, serialization, and location normalization.

The previous behavior of the router was inconsistent:

  • defaultUrlMatcher strictly required all segments to be consumed, causing path: 'a' to fail matching /a/ (which leaves an empty segment), despite pathMatch: 'prefix' variants succeeding via empty children.
  • Location service stripped trailing slashes by default.
  • DefaultUrlSerializer preserved them by default.

This change implements a "Permissive Matcher, Strict Serializer" pattern:

  1. Permissive Matcher: The defaultUrlMatcher has been updated to unconditionally consume a single trailing empty segment if it is the only thing left for a pathMatch: 'full' route. This treats valid trailing slashes as effectively optional during matching, ensuring /a/ matches path: 'a'.
  2. Strict Serializer: The DefaultUrlSerializer now enforces the configured policy:
    • 'always': Forces a trailing slash on all URLs.
    • 'never': Removes trailing slashes from all URLs.
    • (Default): Falls back to permissive matching with whatever the serializer produces (matches strict 'never' behavior if Location strips it, or preserves if Location doesn't).
  3. Location Alignment: The REMOVE_TRAILING_SLASH token logic is updated to only strip slashes when trailingSlash: 'never'.

Alternatives Considered: 'preserve'
A 'preserve' option to respect the exact input format. However, this was dropped because:

  • Ambiguity: Representing trailing slashes as "ephemeral" empty segments (ignored during matching but restored for serialization) required complex synchronization logic in Recognize and UrlTree creation. If we instead consumed the trailing slash in the matchers, this can break assumptions existing code has made about the UrlSegment array in ActivatedRouteSnapshot. The dual nature of representing a URL through both the UrlTree and ActivatedRoute makes things quite difficult.
  • Complexity: "Preserving" phantom empty segments essentially polluted the ActivatedRouteSnapshot or required hacks that broke child matching and relative link generation.
  • Sufficiency: The permissive matcher solves the core user issue (matching failures), while 'always'/'never' strictly effectively cover the canonicalization needs.

If preserve is truly something that's needed in the future, we can
consider an option that appends the empty segment at the end of
recognize to the final UrlTree. This would allow the browser URL
to retain the trailing slash, but the tree of ActivatedRouteSnapshot
would not, meaning that self-links (e.g. '.') generated from those snapshots (e.g. via
RouterLink) would strip the trailing slash.

Alternatives Considered: 'UrlTree.hasTrailingSnapshot'

*UrlTree.hasTrailingSlash Property: adding a explicit boolean property to UrlTree. However, this would have required a public API change and changes to custom UrlSerializer implementations to ensure the state was preserved. It also complicated the mental model of the UrlTree as a direct representation of the URL structure. Currently, parsers will parse the trailing slash into an empty segment, but ideally they would parse the trailing slash as 'hasTrailingSlash: true' without an empty segment.

Why the current approach: A "Permissive Matcher, Strict Serializer/Location" pattern.

  • The Matcher is permissive: it ignoring consuming a trailing empty segment if it's the only thing left with pathMatch: 'full'. The matching logic also allows an empty trailing segment to not be consumed by a matcher.
  • The Serializer/Location are strict: The trailingSlash configuration is verified at the boundaries (serialization and location normalization). If trailingSlash: 'never' is set, the slash is stripped before matching ever occurs. If matching sees a slash, it implies the configuration allowed it (or we are in a permissive mode), so the matcher should handle it gracefully.

(reattempt of #66276)

@atscott atscott added target: feature This PR is targeted for a feature branch (outside of main and semver branches) requires: TGP This PR requires a passing TGP before merging is allowed labels Jan 8, 2026
@angular-robot angular-robot bot added detected: feature PR contains a feature commit area: router labels Jan 8, 2026
@ngbot ngbot bot added this to the Backlog milestone Jan 8, 2026
@atscott atscott force-pushed the trailingslashbackup branch from b3bfe47 to 297c24b Compare January 9, 2026 17:02
This commit introduces  configuration to the Angular Router ('always' | 'never'), resolving long-standing inconsistencies between matching, serialization, and location normalization.

The previous behavior of the router was inconsistent:
- `defaultUrlMatcher` strictly required all segments to be consumed, causing `path: 'a'` to fail matching `/a/` (which leaves an empty segment), despite `pathMatch: 'prefix'` variants succeeding via empty children.
- `Location` service stripped trailing slashes by default.
- `DefaultUrlSerializer` preserved them by default.

This change implements a "Permissive Matcher, Strict Serializer" pattern:

1.  **Permissive Matcher**: The `defaultUrlMatcher` has been updated to unconditionally consume a single trailing empty segment if it is the only thing left for a `pathMatch: 'full'` route. This treats valid trailing slashes as effectively optional during matching, ensuring `/a/` matches `path: 'a'`.
2.  **Strict Serializer**: The `DefaultUrlSerializer` now enforces the configured policy:
    -   **'always'**: Forces a trailing slash on all URLs.
    -   **'never'**: Removes trailing slashes from all URLs.
    -   (Default): Falls back to permissive matching with whatever the serializer produces (matches strict 'never' behavior if `Location` strips it, or preserves if `Location` doesn't).
3.  **Location Alignment**: The `REMOVE_TRAILING_SLASH` token logic is updated to only strip slashes when `trailingSlash: 'never'`.

**Alternatives Considered: 'preserve'**
A 'preserve' option to respect the exact input format. However, this was dropped because:
-   **Ambiguity**: Representing trailing slashes as "ephemeral" empty segments (ignored during matching but restored for serialization) required complex synchronization logic in `Recognize` and `UrlTree` creation. If we instead consumed the trailing slash in the matchers, this can break assumptions existing code has made about the `UrlSegment` array in `ActivatedRouteSnapshot`. The dual nature of representing a URL through both the `UrlTree` and `ActivatedRoute` makes things quite difficult.
-   **Complexity**: "Preserving" phantom empty segments essentially polluted the `ActivatedRouteSnapshot` or required hacks that broke child matching and relative link generation.
-   **Sufficiency**: The permissive matcher solves the core user issue (matching failures), while 'always'/'never' strictly effectively cover the canonicalization needs.

If `preserve` is truly something that's needed in the future, we can
consider an option that appends the empty segment at the end of
`recognize` to the final `UrlTree`. This would allow the browser URL
to retain the trailing slash, but the tree of `ActivatedRouteSnapshot`
would not, meaning that self-links (e.g. '.') generated from those snapshots (e.g. via
`RouterLink`) would strip the trailing slash.

**Alternatives Considered: 'UrlTree.hasTrailingSnapshot'**

*`UrlTree.hasTrailingSlash` Property: adding a explicit boolean property to UrlTree. However, this would have required a public API change and changes to custom UrlSerializer implementations to ensure the state was preserved. It also complicated the mental model of the UrlTree as a direct representation of the URL structure. Currently, parsers will parse the trailing slash into an empty segment, but ideally they would parse the trailing slash as 'hasTrailingSlash: true' without an empty segment.

Why the current approach: A "Permissive Matcher, Strict Serializer/Location" pattern.

* The Matcher is permissive: it ignoring consuming a trailing empty segment if it's the only thing left with pathMatch: 'full'. The matching logic also allows an empty trailing segment to not be consumed by a matcher.
* The Serializer/Location are strict: The trailingSlash configuration is verified at the boundaries (serialization and location normalization). If trailingSlash: 'never' is set, the slash is stripped before matching ever occurs. If matching sees a slash, it implies the configuration allowed it (or we are in a permissive mode), so the matcher should handle it gracefully.
@atscott atscott force-pushed the trailingslashbackup branch from 297c24b to 3a8f450 Compare January 9, 2026 17:07
atscott added a commit to atscott/angular that referenced this pull request Jan 12, 2026
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 (angular#66452 and angular#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 angular#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: angular#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 angular#16051
atscott added a commit to atscott/angular that referenced this pull request Jan 12, 2026
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 (angular#66452 and angular#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 angular#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: angular#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 angular#16051
atscott added a commit that referenced this pull request Jan 23, 2026
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
@atscott atscott closed this Jan 23, 2026
@angular-automatic-lock-bot
Copy link
Copy Markdown

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Feb 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area: router detected: feature PR contains a feature commit requires: TGP This PR requires a passing TGP before merging is allowed target: feature This PR is targeted for a feature branch (outside of main and semver branches)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant