Skip to content

fix(@angular/ssr): resolve parameters in redirect targets - #34321

Open
bschaeublin wants to merge 4 commits into
angular:mainfrom
bschaeublin:fix/ssr-redirect-target-params
Open

bschaeublin wants to merge 4 commits into
angular:mainfrom
bschaeublin:fix/ssr-redirect-target-params

Conversation

@bschaeublin

@bschaeublin bschaeublin commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

PR Checklist

Please check to confirm your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • Other... Please describe:

What is the current behavior?

Follow-up to #34301 (comment).

Parameters in a redirectTo target are sent unchanged in the server's Location header, while the router replaces them:

Route Request Server Location Router navigates to
user/:id → profile/:id /user/7 /profile/:id /profile/7
search/:term → /find?q=:x /search/cats?x=dogs /find?q=:x /find?q=dogs

What is the new behavior?

The server resolves both, one commit each, like the router does:

  1. Route parameters: during route extraction, :name in the redirect path becomes *<n>, the position of that parameter in the request path, which buildPathWithParams resolves at request time. Prerendered routes get the value from getPrerenderParams. Only parameters of the redirecting route's own path can be used; any other :name is reported as an extraction error, matching the router's NG04001.
  2. Query placeholders of absolute redirects: q=:x takes the values of x from the request query, or undefined if it is missing.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

The new extraction error only affects configs that the router already rejects with NG04001. Routes with a custom matcher are left unchanged, since their parameters are only known at runtime.

Closes: #34410

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 210 to 214
const resolvedParts = toPathParts.map((part, index) =>
toPathParts[index] === '*' ? fromPathParts[index] : part,
part === '*'
? fromPathParts[index]
: part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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] ?? ''),
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/angular/ssr/src/routes/ng-routes.ts
Comment thread packages/angular/ssr/src/utils/url.ts Outdated
@alan-agius4
alan-agius4 self-requested a review October 19, 2026 19:43
@alan-agius4 alan-agius4 added the action: review The PR is still awaiting reviews from at least one requested reviewer label Oct 19, 2026
@alan-agius4 alan-agius4 added action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews target: patch This PR is targeted for the next patch release and removed action: review The PR is still awaiting reviews from at least one requested reviewer labels Oct 20, 2026

@alan-agius4 alan-agius4 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! I left a few comments where the behavior diverges from @angular/router or introduces extra behavior.

Comment on lines 865 to 878
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}`;
},
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this in extractRoutesAndCreateRouteTree against fullRoute (and in handleSSGRoute against params) differs from @angular/router in a few ways:

  1. Parent route parameters: In @angular/router (Recognizer.expandSegmentAgainstRouteUsingRedirect), positionalParamSegments (posParams) only contains parameters from the redirecting route's own path, not ancestor routes (parentRoute). Resolving against fullRoute (or all params in handleSSGRoute) allows redirectTo on a child route to reference :param from a parent route, whereas the client router throws NG04001: Cannot redirect to '...'. Cannot find ':param'.
  2. Double replacement for SSG routes: Prerendered routes already run replaceRedirectParams inside handleSSGRoute, and then run through replaceRedirectParams a second time here (which would re-process any prerender parameter value that happens to start with :). Conversely, getRoutesFromAngularRouterConfig (which is exported) returns unresolved :param in redirectTo for non-prerendered routes.
  3. Unknown parameters: When redirectTo contains a :param not present in the route's own path, @angular/router throws NG04001 (Cannot redirect to '${redirectTo}'. Cannot find '${redirectToUrlSegment.path}'.), whereas replaceRedirectParams silently keeps :param as 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/angular/ssr/src/utils/url.ts Outdated
Comment on lines 208 to 216
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +234 to +254
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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few behavioral differences here compared to @angular/router's ApplyRedirects.createQueryParams:

  1. Repeated target query keys (?q=:a&q=:b): In @angular/router, DefaultUrlSerializer.parse groups repeated keys into an array ([':a', ':b']), and createQueryParams checks typeof v === 'string' && v[0] === ':', so repeated keys are not resolved as placeholders. Iterating over URLSearchParams entries resolves each occurrence individually.
  2. Missing source query parameter (?q=:missing): In @angular/router, res[k] = actualParams[sourceName] assigns undefined, which serializes as q=undefined, whereas fromParams.getAll(...) omits q completely.
  3. Query string re-encoding: URLSearchParams.toString() uses application/x-www-form-urlencoded encoding (e.g. spaces become +, and :, @, $, ,, ; are percent-encoded), whereas Angular Router's DefaultUrlSerializer encodes spaces as %20 and 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done:

  1. Placeholders of repeated target keys are no longer resolved.
  2. A missing source parameter results in undefined.
  3. Only the name=:x pairs are replaced; everything else stays as written. Resolved values are encoded like DefaultUrlSerializer (%20 for 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.

Comment on lines +275 to +281
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);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@bschaeublin
bschaeublin force-pushed the fix/ssr-redirect-target-params branch 2 times, most recently from 7661712 to 1281fa8 Compare October 20, 2026 17:21
@alan-agius4 alan-agius4 added action: review The PR is still awaiting reviews from at least one requested reviewer and removed action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews labels Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: review The PR is still awaiting reviews from at least one requested reviewer area: @angular/ssr target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSR redirects a redirectTo that uses a route parameter to the literal :id instead of the parameter value

2 participants