Repository navigation
fix(@angular/ssr): resolve parameters in redirect targets - #34321
bschaeublin wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances Angular SSR's redirect handling by resolving route and query parameter placeholders in redirect targets, aligning its behavior with the Angular router. It introduces buildQueryWithParams to resolve query placeholders, supports positional placeholders (*) in buildPathWithParams, and adds corresponding unit tests. The review feedback highlights a potential runtime error in buildPathWithParams when positional placeholder indices are out of bounds, suggesting a fallback to empty strings. Additionally, it recommends using .startsWith(':') instead of direct index access [0] in both replaceRedirectParams and buildQueryWithParams to safely handle empty strings.
| const resolvedParts = toPathParts.map((part, index) => | ||
| toPathParts[index] === '*' ? fromPathParts[index] : part, | ||
| part === '*' | ||
| ? fromPathParts[index] | ||
| : part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]), | ||
| ); |
There was a problem hiding this comment.
If fromPath has fewer segments than toPath, or if the positional placeholder index is out of bounds, fromPathParts[index] or fromPathParts[position] will be undefined. This will cause joinUrlParts to throw a TypeError when accessing part.length on the undefined value, or result in the string 'undefined' being inserted into the path. Adding a fallback to an empty string prevents these runtime errors.
| const resolvedParts = toPathParts.map((part, index) => | |
| toPathParts[index] === '*' ? fromPathParts[index] : part, | |
| part === '*' | |
| ? fromPathParts[index] | |
| : part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]), | |
| ); | |
| const resolvedParts = toPathParts.map((part, index) => | |
| part === '*' | |
| ? (fromPathParts[index] ?? '') | |
| : part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position] ?? ''), | |
| ); |
There was a problem hiding this comment.
Not reachable: each */*<n> placeholder is derived from the position of a parameter in the matched route, and the route tree only matches when the request has a segment at each of those positions.
alan-agius4
left a comment
There was a problem hiding this comment.
Thanks for working on this! I left a few comments where the behavior diverges from @angular/router or introduces extra behavior.
| const fullRoute = joinUrlParts(baseHref, route); | ||
|
|
||
| if (metadata.redirectTo !== undefined) { | ||
| metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo); | ||
| // Reference the route parameters by their position in the URL (see `buildPathWithParams`). | ||
| const routeSegments = fullRoute.split('/'); | ||
| metadata.redirectTo = replaceRedirectParams( | ||
| joinUrlParts(baseHref, metadata.redirectTo), | ||
| (parameter) => { | ||
| const position = routeSegments.lastIndexOf(parameter); | ||
|
|
||
| return position === -1 ? undefined : `*${position}`; | ||
| }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Doing this in extractRoutesAndCreateRouteTree against fullRoute (and in handleSSGRoute against params) differs from @angular/router in a few ways:
- Parent route parameters: In
@angular/router(Recognizer.expandSegmentAgainstRouteUsingRedirect),positionalParamSegments(posParams) only contains parameters from the redirecting route's ownpath, not ancestor routes (parentRoute). Resolving againstfullRoute(or allparamsinhandleSSGRoute) allowsredirectToon a child route to reference:paramfrom a parent route, whereas the client router throwsNG04001: Cannot redirect to '...'. Cannot find ':param'. - Double replacement for SSG routes: Prerendered routes already run
replaceRedirectParamsinsidehandleSSGRoute, and then run throughreplaceRedirectParamsa second time here (which would re-process any prerender parameter value that happens to start with:). Conversely,getRoutesFromAngularRouterConfig(which is exported) returns unresolved:paraminredirectTofor non-prerendered routes. - Unknown parameters: When
redirectTocontains a:paramnot present in the route's ownpath,@angular/routerthrowsNG04001(Cannot redirect to '${redirectTo}'. Cannot find '${redirectToUrlSegment.path}'.), whereasreplaceRedirectParamssilently keeps:paramas a literal segment.
Can we resolve redirectTo parameters inside handleRoute / handleSSGRoute where route.path and parentRoute are still distinct, only matching parameters defined in route.path and reporting an extraction error when a :param in redirectTo is not found in route.path?
There was a problem hiding this comment.
Done. redirectTo parameters are now resolved in handleRoute/handleSSGRoute against the redirecting route's own path only, so getRoutesFromAngularRouterConfig returns them resolved and extract() no longer touches them. A :param that is not in the route's own path (including parent parameters) now reports the router's error: Cannot redirect to '…'. Cannot find ':param'.
One exception: routes with a custom matcher are left as is, since their positional parameters are only known at runtime and an error would reject a valid config.
| const fromPathParts = fromPath.split('/'); | ||
| const toPathParts = toPath.split('/'); | ||
| const resolvedParts = toPathParts.map((part, index) => | ||
| toPathParts[index] === '*' ? fromPathParts[index] : part, | ||
| part === '*' | ||
| ? fromPathParts[index] | ||
| : part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]), | ||
| ); | ||
|
|
||
| return joinUrlParts(...resolvedParts); |
There was a problem hiding this comment.
Because toPath can include a query string or fragment (for absolute redirects), calling toPath.split('/') and joinUrlParts(...resolvedParts) on the entire string will also split the query/fragment on /.
For example, /user/*1?returnTo=/a//b/ would have its query string split into parts, // collapsed, and trailing / stripped by joinUrlParts (and any /*<n> in the query/fragment replaced).
We should split off the query/fragment before splitting the path on / (similar to replaceRedirectParams), resolve the path segments, and then re-attach the suffix. That also removes the need for (?=[?#]|$) in POSITIONAL_PLACEHOLDER_REGEXP.
There was a problem hiding this comment.
Good catch, fixed. buildPathWithParams now splits off the query/fragment first, only resolves the path segments and re-attaches the suffix unchanged. The lookahead in POSITIONAL_PLACEHOLDER_REGEXP is gone.
| export function buildQueryWithParams(toUrl: string, fromParams: URLSearchParams): string { | ||
| const [, path, query, fragment] = /^([^?#]*)\?([^#]*)(.*)$/.exec(toUrl) ?? []; | ||
| if (query === undefined || !query.includes('=:')) { | ||
| return toUrl; | ||
| } | ||
|
|
||
| const resolvedParams = new URLSearchParams(); | ||
| for (const [name, value] of new URLSearchParams(query)) { | ||
| if (value[0] === ':') { | ||
| for (const sourceValue of fromParams.getAll(value.slice(1))) { | ||
| resolvedParams.append(name, sourceValue); | ||
| } | ||
| } else { | ||
| resolvedParams.append(name, value); | ||
| } | ||
| } | ||
|
|
||
| const resolvedQuery = resolvedParams.toString(); | ||
|
|
||
| return path + (resolvedQuery ? `?${resolvedQuery}` : '') + fragment; | ||
| } |
There was a problem hiding this comment.
There are a few behavioral differences here compared to @angular/router's ApplyRedirects.createQueryParams:
- Repeated target query keys (
?q=:a&q=:b): In@angular/router,DefaultUrlSerializer.parsegroups repeated keys into an array ([':a', ':b']), andcreateQueryParamscheckstypeof v === 'string' && v[0] === ':', so repeated keys are not resolved as placeholders. Iterating overURLSearchParamsentries resolves each occurrence individually. - Missing source query parameter (
?q=:missing): In@angular/router,res[k] = actualParams[sourceName]assignsundefined, which serializes asq=undefined, whereasfromParams.getAll(...)omitsqcompletely. - Query string re-encoding:
URLSearchParams.toString()usesapplication/x-www-form-urlencodedencoding (e.g. spaces become+, and:,@,$,,,;are percent-encoded), whereas Angular Router'sDefaultUrlSerializerencodes spaces as%20and preserves@,:,$,,,;. Also,!query.includes('=:')will false-positive if a static query value contains=:mid-value (e.g.?filter=a=:b), re-encoding the entire query string unnecessarily.
There was a problem hiding this comment.
Done:
- Placeholders of repeated target keys are no longer resolved.
- A missing source parameter results in
undefined. - Only the
name=:xpairs are replaced; everything else stays as written. Resolved values are encoded likeDefaultUrlSerializer(%20for spaces,@ : $ , ;kept).
Static values are kept as written, following your note that re-encoding the entire query is unnecessary. The router itself would normalize them too (e.g. ?a=b+c becomes a=b%20c), so let me know if you'd rather have full parity there.
| it('should resolve query parameters in absolute redirect targets', async () => { | ||
| const response = await app.handle( | ||
| new Request('http://localhost/redirect-query-placeholder?term=cats&term=dogs'), | ||
| ); | ||
| expect(response?.headers.get('location')).toBe('/home?q=cats&q=dogs&static=value'); | ||
| expect(response?.status).toBe(302); | ||
| }); |
There was a problem hiding this comment.
Note that with @angular/router, id=:missing when missing is not in the request query string produces id=undefined (/home?q=cats&q=dogs&id=undefined&static=value) rather than omitting id.
There was a problem hiding this comment.
Updated, the test now expects /home?q=cats&q=dogs&id=undefined&static=value.
Route parameters in the path of a `redirectTo` target, such as `:id` in `profile/:id`, were sent unchanged in the `Location` header by the server, while the router replaces them with the matching parameters of the current URL. The server now does the same.
…argets Query parameters of an absolute `redirectTo` target whose value is a placeholder, such as `q=:term`, were sent unchanged in the `Location` header by the server, while the router replaces them with the values of the matching query parameters of the current URL. The server now does the same. Query parameters whose source parameter is missing in the current URL are left out, which matches the `undefined` value that the router assigns to them.
7661712 to
1281fa8
Compare
PR Checklist
Please check to confirm your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
Follow-up to #34301 (comment).
Parameters in a
redirectTotarget are sent unchanged in the server'sLocationheader, while the router replaces them:Locationuser/:id→profile/:id/user/7/profile/:id/profile/7search/:term→/find?q=:x/search/cats?x=dogs/find?q=:x/find?q=dogsWhat is the new behavior?
The server resolves both, one commit each, like the router does:
:namein the redirect path becomes*<n>, the position of that parameter in the request path, whichbuildPathWithParamsresolves at request time. Prerendered routes get the value fromgetPrerenderParams. Only parameters of the redirecting route's ownpathcan be used; any other:nameis reported as an extraction error, matching the router'sNG04001.q=:xtakes the values ofxfrom the request query, orundefinedif it is missing.Does this PR introduce a breaking change?
Other information
The new extraction error only affects configs that the router already rejects with
NG04001. Routes with a custommatcherare left unchanged, since their parameters are only known at runtime.Closes: #34410