feat(router): refine trailing slash handling with hybrid ephemeral/append strategy#66423
Closed
atscott wants to merge 1 commit intoangular:mainfrom
Closed
feat(router): refine trailing slash handling with hybrid ephemeral/append strategy#66423atscott wants to merge 1 commit intoangular:mainfrom
atscott wants to merge 1 commit intoangular:mainfrom
Conversation
b3bfe47 to
297c24b
Compare
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.
297c24b to
3a8f450
Compare
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
|
This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
defaultUrlMatcherstrictly required all segments to be consumed, causingpath: 'a'to fail matching/a/(which leaves an empty segment), despitepathMatch: 'prefix'variants succeeding via empty children.Locationservice stripped trailing slashes by default.DefaultUrlSerializerpreserved them by default.This change implements a "Permissive Matcher, Strict Serializer" pattern:
defaultUrlMatcherhas been updated to unconditionally consume a single trailing empty segment if it is the only thing left for apathMatch: 'full'route. This treats valid trailing slashes as effectively optional during matching, ensuring/a/matchespath: 'a'.DefaultUrlSerializernow enforces the configured policy:Locationstrips it, or preserves ifLocationdoesn't).REMOVE_TRAILING_SLASHtoken logic is updated to only strip slashes whentrailingSlash: 'never'.Alternatives Considered: 'preserve'
A 'preserve' option to respect the exact input format. However, this was dropped because:
RecognizeandUrlTreecreation. If we instead consumed the trailing slash in the matchers, this can break assumptions existing code has made about theUrlSegmentarray inActivatedRouteSnapshot. The dual nature of representing a URL through both theUrlTreeandActivatedRoutemakes things quite difficult.ActivatedRouteSnapshotor required hacks that broke child matching and relative link generation.If
preserveis truly something that's needed in the future, we canconsider an option that appends the empty segment at the end of
recognizeto the finalUrlTree. This would allow the browser URLto retain the trailing slash, but the tree of
ActivatedRouteSnapshotwould 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.hasTrailingSlashProperty: 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.
(reattempt of #66276)