Skip to content

Commit b39ec95

Browse files
committed
refactor(@angular/ssr): combine redirect URL parameter helpers and use DefaultUrlSerializer
Combine `buildPathWithParams` and `buildQueryWithParams` into a single `buildUrlWithParams` helper that uses `DefaultUrlSerializer` from `@angular/router` for query parameter parsing and serialization. Also avoid unnecessary regex/string splitting when redirect targets do not contain parameter placeholders, and only apply `joinUrlParts` to the path portion of redirect URLs so trailing slashes in query parameters or fragments are preserved.
1 parent 8aa16b6 commit b39ec95

4 files changed

Lines changed: 122 additions & 149 deletions

File tree

‎packages/angular/ssr/src/app.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,7 @@ import { ServerRouter } from './routes/router';
2424
import { AngularBootstrap, renderAngular } from './utils/ng';
2525
import { promiseWithAbort } from './utils/promise';
2626
import { createRedirectResponse } from './utils/redirect';
27-
import {
28-
buildPathWithParams,
29-
buildQueryWithParams,
30-
joinUrlParts,
31-
stripLeadingSlash,
32-
} from './utils/url';
27+
import { buildUrlWithParams, joinUrlParts, stripLeadingSlash } from './utils/url';
3328
import { getFirstHeaderValue } from './utils/validation';
3429

3530
/**
@@ -172,14 +167,24 @@ export class AngularServerApp {
172167
const { redirectTo, relativeRedirect, status, renderMode, headers } = matchedRoute;
173168

174169
if (redirectTo !== undefined) {
175-
const location = joinUrlParts(
176-
getFirstHeaderValue(request.headers.get('X-Forwarded-Prefix')) ?? '',
177-
buildPathWithParams(redirectTo, url.pathname),
170+
const prefix = getFirstHeaderValue(request.headers.get('X-Forwarded-Prefix'));
171+
let location = buildUrlWithParams(
172+
redirectTo,
173+
url.pathname,
174+
relativeRedirect ? undefined : url.search,
178175
);
179176

177+
if (prefix) {
178+
const searchIndex = location.search(/[?#]/);
179+
location =
180+
searchIndex === -1
181+
? joinUrlParts(prefix, location)
182+
: joinUrlParts(prefix, location.slice(0, searchIndex)) + location.slice(searchIndex);
183+
}
184+
180185
// Match Angular router behavior: relative redirects keep the query string.
181186
return createRedirectResponse(
182-
relativeRedirect ? location + url.search : buildQueryWithParams(location, url.searchParams),
187+
relativeRedirect ? location + url.search : location,
183188
status,
184189
headers,
185190
);

‎packages/angular/ssr/src/routes/ng-routes.ts‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,17 @@ async function* handleRoute(options: {
203203
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
204204
};
205205
} else if (typeof redirectTo === 'string') {
206-
// Reference the route parameters by their position in the URL (see `buildPathWithParams`).
207-
const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/');
206+
let routeSegments: string[] | undefined;
208207
parentRoutes.set(metadata.route, {
209208
...metadata,
210209
redirectTo: resolveRedirectTo(
211210
parentRoute,
212-
replaceRedirectParams(
213-
route,
214-
redirectTo,
215-
(parameter) => `*${routeSegments.lastIndexOf(parameter)}`,
216-
),
211+
// Reference the route parameters by their position in the URL (see `buildUrlWithParams`).
212+
replaceRedirectParams(route, redirectTo, (parameter) => {
213+
routeSegments ??= joinUrlParts(baseHref, currentRoutePath).split('/');
214+
215+
return `*${routeSegments.lastIndexOf(parameter)}`;
216+
}),
217217
),
218218
relativeRedirect: redirectTo[0] !== '/' ? true : undefined,
219219
});
@@ -468,15 +468,15 @@ async function* handleSSGRoute(
468468

469469
const redirectTo = typeof route.redirectTo === 'string' ? route.redirectTo : undefined;
470470
if (redirectTo !== undefined) {
471-
// Reference the route parameters by their position in the URL (see `buildPathWithParams`).
472-
const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/');
471+
let routeSegments: string[] | undefined;
473472
meta.redirectTo = resolveRedirectTo(
474473
parentRoute,
475-
replaceRedirectParams(
476-
route,
477-
redirectTo,
478-
(parameter) => `*${routeSegments.lastIndexOf(parameter)}`,
479-
),
474+
// Reference the route parameters by their position in the URL (see `buildUrlWithParams`).
475+
replaceRedirectParams(route, redirectTo, (parameter) => {
476+
routeSegments ??= joinUrlParts(baseHref, currentRoutePath).split('/');
477+
478+
return `*${routeSegments.lastIndexOf(parameter)}`;
479+
}),
480480
);
481481
meta.relativeRedirect = redirectTo[0] !== '/' ? true : undefined;
482482
}
@@ -635,13 +635,17 @@ function replaceRedirectParams(
635635
redirectTo: string,
636636
resolve: (parameter: string) => string,
637637
): string {
638-
if (route.matcher) {
638+
if (route.matcher || !redirectTo.includes(':')) {
639639
// The parameters of a custom matcher are only known at runtime.
640640
return redirectTo;
641641
}
642642

643-
const routeSegments = route.path?.split('/') ?? [];
644643
const [path] = redirectTo.split(/[?#]/, 1);
644+
if (!path.includes(':')) {
645+
return redirectTo;
646+
}
647+
648+
const routeSegments = route.path?.split('/') ?? [];
645649
const resolvedPath = path
646650
.split('/')
647651
.map((segment) => {
@@ -935,7 +939,12 @@ export function extractRoutesAndCreateRouteTree(options: {
935939

936940
for (const { route, ...metadata } of routes) {
937941
if (metadata.redirectTo !== undefined) {
938-
metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo);
942+
const searchIndex = metadata.redirectTo.search(/[?#]/);
943+
metadata.redirectTo =
944+
searchIndex === -1
945+
? joinUrlParts(baseHref, metadata.redirectTo)
946+
: joinUrlParts(baseHref, metadata.redirectTo.slice(0, searchIndex)) +
947+
metadata.redirectTo.slice(searchIndex);
939948
}
940949

941950
// Remove undefined fields

‎packages/angular/ssr/src/utils/url.ts‎

Lines changed: 55 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { DefaultUrlSerializer } from '@angular/router';
10+
911
/**
1012
* Removes the trailing slash from a URL if it exists.
1113
*
@@ -153,123 +155,80 @@ export function stripIndexHtmlFromURL(url: URL): URL {
153155
}
154156

155157
/**
156-
* Matches a `*<n>` placeholder that makes up a whole path segment.
158+
* Cached instance of `DefaultUrlSerializer` used for query parameter parsing and serialization.
157159
*/
158-
const POSITIONAL_PLACEHOLDER_REGEXP = /^\*(\d+)$/;
160+
let urlSerializer: DefaultUrlSerializer | undefined;
159161

160162
/**
161-
* Resolves `*` placeholders in a path template by mapping them to corresponding segments
162-
* from a base path. This is useful for constructing paths dynamically based on a given base path.
163-
*
164-
* The function processes the `toPath` string, replacing each `*` placeholder with
165-
* the corresponding segment from the `fromPath`, and each `*<n>` placeholder with the
166-
* segment at position `n` of the `fromPath`. If the `toPath` contains no placeholders,
167-
* it is returned as-is. Invalid `toPath` formats (not starting with `/`) will throw an error.
168-
*
169-
* @param toPath - A path template string that may contain `*` placeholders. Each `*` is replaced
170-
* by the corresponding segment from the `fromPath`. Static paths (e.g., `/static/path`) are returned
171-
* directly without placeholder replacement.
172-
* @param fromPath - A base path string, split into segments, that provides values for
173-
* replacing `*` placeholders in the `toPath`.
174-
* @returns A resolved path string with `*` placeholders replaced by segments from the `fromPath`,
175-
* or the `toPath` returned unchanged if it contains no placeholders.
163+
* Resolves `*` and `*<n>` placeholders in the path of `toUrl` from `fromPath`, and `:name`
164+
* placeholders in the query of `toUrl` from `fromSearch`, matching Angular router redirect semantics.
176165
*
177-
* @throws If the `toPath` does not start with a `/`, indicating an invalid path format.
166+
* - Each `*` path segment is replaced with the segment at the same index in `fromPath`.
167+
* - Each `*<n>` path segment is replaced with the segment at index `n` in `fromPath`.
168+
* - When `fromSearch` is provided, each query parameter whose value is `:name` is replaced with the
169+
* values of `name` from `fromSearch`, or `undefined` if missing. Repeated query parameters are not
170+
* resolved, matching the Angular router.
178171
*
179-
* @example
180-
* ```typescript
181-
* // Example with placeholders resolved
182-
* const resolvedPath = buildPathWithParams('/*\/details', '/123/abc');
183-
* console.log(resolvedPath); // Outputs: '/123/details'
184-
*
185-
* // Example with a positional placeholder
186-
* const positionalPath = buildPathWithParams('/user/*1', '/123/abc');
187-
* console.log(positionalPath); // Outputs: '/user/123'
188-
*
189-
* // Example with a static path
190-
* const staticPath = buildPathWithParams('/static/path', '/base/unused');
191-
* console.log(staticPath); // Outputs: '/static/path'
192-
* ```
172+
* @param toUrl - A URL template string starting with `/`.
173+
* @param fromPath - A base path string starting with `/` that provides segments for path placeholders.
174+
* @param fromSearch - Optional query string (e.g. `url.search`) that provides values for query placeholders.
175+
* @returns The URL with its path and query placeholders resolved.
193176
*/
194-
export function buildPathWithParams(toPath: string, fromPath: string): string {
195-
if (toPath[0] !== '/') {
196-
throw new Error(`Invalid toPath: The string must start with a '/'. Received: '${toPath}'`);
177+
export function buildUrlWithParams(toUrl: string, fromPath: string, fromSearch?: string): string {
178+
if (toUrl[0] !== '/') {
179+
throw new Error(`Invalid toUrl: The string must start with a '/'. Received: '${toUrl}'`);
197180
}
198181

199182
if (fromPath[0] !== '/') {
200183
throw new Error(`Invalid fromPath: The string must start with a '/'. Received: '${fromPath}'`);
201184
}
202185

203-
const [path] = toPath.split(/[?#]/, 1);
204-
if (!path.includes('/*')) {
205-
return toPath;
186+
const hasPathParams = toUrl.includes('/*');
187+
const hasQueryParams = Boolean(fromSearch !== undefined && toUrl.includes('=:'));
188+
if (!hasPathParams && !hasQueryParams) {
189+
return toUrl;
206190
}
207191

208-
const fromPathParts = fromPath.split('/');
209-
const toPathParts = path.split('/');
210-
const resolvedParts = toPathParts.map((part, index) =>
211-
part === '*'
212-
? fromPathParts[index]
213-
: part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]),
214-
);
192+
const searchIndex = toUrl.search(/[?#]/);
193+
let path = searchIndex === -1 ? toUrl : toUrl.slice(0, searchIndex);
194+
let suffix = searchIndex === -1 ? '' : toUrl.slice(searchIndex);
215195

216-
return joinUrlParts(...resolvedParts) + toPath.slice(path.length);
217-
}
218-
219-
/**
220-
* Resolves `:name` placeholders in the query of a URL, like the Angular router does for redirects.
221-
*
222-
* Each query parameter whose value is a `:name` placeholder gets the values of the `name`
223-
* query parameter of `fromParams`, or `undefined` if `fromParams` has no such parameter.
224-
* Like in the Angular router, placeholders of repeated query parameters are not resolved.
225-
*
226-
* @param toUrl - A URL whose query may contain `:name` placeholders.
227-
* @param fromParams - The query parameters that provide the values for the placeholders.
228-
* @returns The URL with its query placeholders resolved.
229-
*
230-
* @example
231-
* ```typescript
232-
* buildQueryWithParams('/find?q=:x', new URLSearchParams('x=cats')); // returns '/find?q=cats'
233-
* ```
234-
*/
235-
export function buildQueryWithParams(toUrl: string, fromParams: URLSearchParams): string {
236-
const [, path, query, fragment] = /^([^?#]*)\?([^#]*)(.*)$/.exec(toUrl) ?? [];
237-
if (query === undefined) {
238-
return toUrl;
239-
}
196+
if (hasPathParams && path.includes('/*')) {
197+
const fromPathParts = fromPath.split('/');
198+
const toPathParts = path.split('/');
199+
const resolvedParts = toPathParts.map((part, index) => {
200+
if (part[0] !== '*') {
201+
return part;
202+
}
240203

241-
const params = query.split('&');
242-
const names = params.map((param) => param.split('=', 1)[0]);
243-
const resolvedQuery = params
244-
.map((param, index) => {
245-
const name = names[index];
246-
const value = param.slice(name.length + 1);
247-
if (value[0] !== ':' || names.indexOf(name) !== names.lastIndexOf(name)) {
248-
return param;
204+
if (part.length === 1) {
205+
return fromPathParts[index];
249206
}
250207

251-
const sourceValues = fromParams.getAll(value.slice(1));
208+
const position = Number(part.slice(1));
252209

253-
return (sourceValues.length ? sourceValues : ['undefined'])
254-
.map((sourceValue) => `${name}=${encodeQueryValue(sourceValue)}`)
255-
.join('&');
256-
})
257-
.join('&');
210+
return Number.isInteger(position) ? fromPathParts[position] : part;
211+
});
258212

259-
return `${path}?${resolvedQuery}${fragment}`;
260-
}
213+
path = joinUrlParts(...resolvedParts);
214+
}
261215

262-
/**
263-
* Encodes a query parameter value like the Angular router (`DefaultUrlSerializer`), which keeps
264-
* `@`, `:`, `$`, `,` and `;` as is.
265-
*
266-
* @param value - The query parameter value to encode.
267-
* @returns The encoded query parameter value.
268-
*/
269-
function encodeQueryValue(value: string): string {
270-
return encodeURIComponent(value).replace(/%(40|3A|24|2C|3B)/g, (match) =>
271-
decodeURIComponent(match),
272-
);
216+
if (hasQueryParams && suffix.includes('=:')) {
217+
urlSerializer ??= new DefaultUrlSerializer();
218+
const toTree = urlSerializer.parse(suffix);
219+
const actualParams = fromSearch ? urlSerializer.parse(fromSearch).queryParams : {};
220+
221+
for (const [key, value] of Object.entries(toTree.queryParams)) {
222+
if (typeof value === 'string' && value[0] === ':') {
223+
toTree.queryParams[key] = actualParams[value.slice(1)];
224+
}
225+
}
226+
227+
// `serialize(toTree)` returns `/?...#...` because `toTree.root` is empty.
228+
suffix = urlSerializer.serialize(toTree).slice(1);
229+
}
230+
231+
return path + suffix;
273232
}
274233

275234
const MATRIX_PARAMS_REGEX = /;[^/]+/g;

0 commit comments

Comments
 (0)