diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 6531a31a00c4..72caca999488 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -31,7 +31,15 @@ import { AngularAppManifest, getAngularAppManifest } from '../manifest'; import { AngularBootstrap, isNgModule } from '../utils/ng'; import { promiseWithAbort } from '../utils/promise'; import { VALID_REDIRECT_RESPONSE_CODES, isValidRedirectResponseCode } from '../utils/redirect'; -import { joinUrlParts, stripLeadingSlash } from '../utils/url'; +import { + type PathSegment, + WILDCARD_SEGMENT, + joinUrlParts, + parsePathSegments, + parseUrl, + serializePathSegments, + stripLeadingSlash, +} from '../utils/url'; import { PrerenderFallback, RenderMode, @@ -179,11 +187,17 @@ async function* handleRoute(options: { const parentRoutes = new Map(); if (typeof redirectTo === 'string') { - // Reference the route parameters by their position in the URL (see `buildUrlWithParams`). - metadata.redirectTo = resolveRedirectTo(parentRoute, route, redirectTo, (parameter) => { - const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/'); + // Reference the route parameters by their 0-based position in the URL (see `buildUrlWithParams`). + const routeSegments = parsePathSegments(joinUrlParts(baseHref, currentRoutePath)); + metadata.redirectTo = resolveRedirectTo(parentRoute, route, redirectTo, (paramName) => { + for (let i = routeSegments.length - 1; i >= 0; i--) { + const segment = routeSegments[i]; + if (segment.type === 'param' && segment.name === paramName) { + return { type: 'wildcard', index: i }; + } + } - return `*${routeSegments.lastIndexOf(parameter)}`; + return WILDCARD_SEGMENT; }); metadata.relativeRedirect = redirectTo[0] !== '/' ? true : undefined; } @@ -518,7 +532,7 @@ async function* handleSSGRoute( parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer), route, redirectTo, - (parameter) => params[parameter.slice(1)], + (paramName) => ({ type: 'static', value: params[paramName] }), ), }; } @@ -581,7 +595,7 @@ function handlePrerenderParamsReplacement( * @param parentRoute - The path of the parent route. * @param route - The redirecting route. * @param redirectTo - The target path for redirection. - * @param resolveParam - A function that returns the replacement for a parameter, such as `:id`. + * @param resolveParam - A function that returns the replacement `PathSegment` for a parameter name. * @returns The resolved redirect path as a string. * @throws If `redirectTo` uses a parameter that is not defined in the path of the route. */ @@ -589,37 +603,40 @@ function resolveRedirectTo( parentRoute: string, route: Route, redirectTo: string, - resolveParam: (parameter: string) => string, + resolveParam: (paramName: string) => PathSegment, ): string { if (redirectTo[0] === '/' && (route.matcher || !redirectTo.includes('/:'))) { return redirectTo; } - const [path] = redirectTo.split(/[?#]/, 1); - let resolvedPath = path; + const parsedRedirect = parseUrl(redirectTo); + let resolvedSegments = parsedRedirect.segments; // The parameters of a custom matcher are only known at runtime. - if (!route.matcher && path.includes(':')) { - const routeSegments = route.path?.split('/') ?? []; - resolvedPath = path - .split('/') - .map((segment) => { - if (segment[0] !== ':') { - return segment; - } + if (!route.matcher && parsedRedirect.hasDynamicSegments) { + const routeSegments = parsePathSegments(route.path ?? ''); + resolvedSegments = resolvedSegments.map((segment) => { + if (segment.type !== 'param') { + return segment; + } - if (!routeSegments.includes(segment)) { - throw new Error(`Cannot redirect to '${redirectTo}'. Cannot find '${segment}'.`); - } + if (!routeSegments.some((s) => s.type === 'param' && s.name === segment.name)) { + throw new Error(`Cannot redirect to '${redirectTo}'. Cannot find ':${segment.name}'.`); + } - return resolveParam(segment); - }) - .join('/'); + return resolveParam(segment.name); + }); } - return redirectTo[0] === '/' - ? resolvedPath + redirectTo.slice(path.length) - : joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), resolvedPath); + if (parsedRedirect.isAbsolute) { + return serializePathSegments(resolvedSegments) + parsedRedirect.rawSuffix; + } + + const parentSegments = parsePathSegments(parentRoute).map((segment) => + segment.type === 'param' ? WILDCARD_SEGMENT : segment, + ); + + return serializePathSegments([...parentSegments, ...resolvedSegments]); } /** diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index f2a2aa776b9e..0071bfefa1b8 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -6,7 +6,52 @@ * found in the LICENSE file at https://angular.dev/license */ -import { DefaultUrlSerializer } from '@angular/router'; +import { DefaultUrlSerializer, type Params, type UrlTree } from '@angular/router'; + +/** + * A parsed path segment in a route or redirect URL template. + * + * - `static`: A literal path segment (e.g., `'users'`). + * - `param`: A named route parameter segment starting with `:` (e.g., `':id'` -> `name: 'id'`). + * - `wildcard`: A positional wildcard segment (`'*'` or `'*'`). + * When `index` is defined, it is the 0-based index of the source segment to substitute; + * when `undefined`, it substitutes the source segment at the current segment position. + */ +export type PathSegment = + | { readonly type: 'static'; readonly value: string } + | { readonly type: 'param'; readonly name: string } + | { readonly type: 'wildcard'; readonly index?: number }; + +/** + * A parsed query parameter in a redirect URL template. + * + * - `static`: A literal query parameter value (single string or repeated `string[]`). + * - `param`: A named query parameter placeholder (`?q=:term` -> `name: 'q'`, `param: 'term'`). + */ +export type QueryParam = + | { readonly type: 'static'; readonly name: string; readonly value: string | string[] } + | { readonly type: 'param'; readonly name: string; readonly param: string }; + +/** + * Structured representation of a parsed URL or route template. + */ +export interface ParsedUrl { + readonly isAbsolute: boolean; + readonly segments: readonly PathSegment[]; + readonly queryParams: readonly QueryParam[]; + readonly fragment: string | null; + readonly rawSuffix: string; + readonly hasDynamicSegments: boolean; + readonly hasDynamicQueryParams: boolean; +} + +/** + * Shared singleton for unindexed wildcard path segments (`'*'`). + */ +export const WILDCARD_SEGMENT: PathSegment = { type: 'wildcard' }; + +const EMPTY_QUERY_PARAMS: readonly QueryParam[] = []; +const POSITIONAL_WILDCARD_REGEX = /^\*\d+$/; /** * Removes the trailing slash from a URL if it exists. @@ -155,16 +200,180 @@ export function stripIndexHtmlFromURL(url: URL): URL { } /** - * Cached instance of `DefaultUrlSerializer` used for query parameter parsing and serialization. + * Cached instance of `DefaultUrlSerializer` and reusable empty `UrlTree` shell + * used for query parameter parsing and serialization. */ let urlSerializer: DefaultUrlSerializer | undefined; +let serializationTree: UrlTree | undefined; + +/** + * Parses a single raw path segment string into a structured `PathSegment`. + */ +function parsePathSegment(segment: string): PathSegment { + if (segment[0] === '*') { + if (segment.length === 1) { + return WILDCARD_SEGMENT; + } + + if (POSITIONAL_WILDCARD_REGEX.test(segment)) { + return { type: 'wildcard', index: Number(segment.slice(1)) }; + } + } else if (segment[0] === ':' && segment.length > 1) { + return { type: 'param', name: segment.slice(1) }; + } + + return { type: 'static', value: segment }; +} + +/** + * Parses a URL path string into a 0-indexed array of structured `PathSegment` objects. + * Leading, trailing, and consecutive slashes are ignored. + * + * @param path - The path string to parse (without query or fragment). + * @returns An array of parsed `PathSegment` objects. + */ +export function parsePathSegments(path: string): PathSegment[] { + const segments: PathSegment[] = []; + let start = 0; + const length = path.length; + + while (start < length) { + while (start < length && path[start] === '/') { + start++; + } + if (start >= length) { + break; + } + + let end = path.indexOf('/', start); + if (end === -1) { + end = length; + } + + segments.push(parsePathSegment(path.slice(start, end))); + start = end + 1; + } + + return segments; +} + +/** + * Extracts raw non-empty segment strings from a URL pathname without allocating empty strings. + */ +function extractPathSegments(path: string): string[] { + const segments: string[] = []; + let start = 0; + const length = path.length; + + while (start < length) { + while (start < length && path[start] === '/') { + start++; + } + if (start >= length) { + break; + } + + let end = path.indexOf('/', start); + if (end === -1) { + end = length; + } + + segments.push(path.slice(start, end)); + start = end + 1; + } + + return segments; +} + +/** + * Serializes a single `PathSegment` into its string representation. + */ +function serializePathSegment(segment: PathSegment): string { + switch (segment.type) { + case 'static': + return segment.value; + case 'param': + return `:${segment.name}`; + case 'wildcard': + return segment.index === undefined ? '*' : `*${segment.index}`; + } +} + +/** + * Serializes an array of `PathSegment` objects into a normalized path starting with `/`. + */ +export function serializePathSegments(segments: readonly PathSegment[]): string { + let result = ''; + for (const segment of segments) { + const value = serializePathSegment(segment); + if (value) { + result += '/' + value; + } + } + + return result || '/'; +} + +/** + * Parses a URL or route template string into a structured `ParsedUrl`. + * + * Query parameters are parsed via `DefaultUrlSerializer` only when dynamic `:name` placeholders + * may be present (`?=:` pattern), avoiding unnecessary serializer overhead for static query strings. + */ +export function parseUrl(url: string): ParsedUrl { + const queryIndex = url.indexOf('?'); + const hashIndex = url.indexOf('#'); + const suffixIndex = + queryIndex === -1 ? hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex); + + const path = suffixIndex === -1 ? url : url.slice(0, suffixIndex); + const rawSuffix = suffixIndex === -1 ? '' : url.slice(suffixIndex); + const segments = parsePathSegments(path); + const hasDynamicSegments = segments.some((segment) => segment.type !== 'static'); + + if (rawSuffix[0] === '?' && rawSuffix.includes('=:')) { + urlSerializer ??= new DefaultUrlSerializer(); + const tree = urlSerializer.parse(rawSuffix); + const queryParams: QueryParam[] = []; + let hasDynamicQueryParams = false; + + for (const [name, value] of Object.entries(tree.queryParams)) { + if (typeof value === 'string' && value[0] === ':') { + queryParams.push({ type: 'param', name, param: value.slice(1) }); + hasDynamicQueryParams = true; + } else { + queryParams.push({ type: 'static', name, value }); + } + } + + return { + isAbsolute: path[0] === '/', + segments, + queryParams, + fragment: tree.fragment, + rawSuffix, + hasDynamicSegments, + hasDynamicQueryParams, + }; + } + + return { + isAbsolute: path[0] === '/', + segments, + queryParams: EMPTY_QUERY_PARAMS, + fragment: hashIndex === -1 ? null : decodeURIComponent(url.slice(hashIndex + 1)), + rawSuffix, + hasDynamicSegments, + hasDynamicQueryParams: false, + }; +} /** * Resolves `*` and `*` placeholders in the path of `toUrl` from `fromPath`, and `:name` * placeholders in the query of `toUrl` from `fromSearch`, matching Angular router redirect semantics. * * - Each `*` path segment is replaced with the segment at the same index in `fromPath`. - * - Each `*` path segment is replaced with the 1-based segment at position `n` in `fromPath`. + * - Each `*` path segment is replaced with the 0-based segment at position `n` in `fromPath`. * - When `fromSearch` is provided, each query parameter whose value is `:name` is replaced with the * values of `name` from `fromSearch`, or `undefined` if missing. Repeated query parameters are not * resolved, matching the Angular router. @@ -177,7 +386,7 @@ let urlSerializer: DefaultUrlSerializer | undefined; * @example * ```ts * buildUrlWithParams('/*\/details', '/123/abc'); // '/123/details' - * buildUrlWithParams('/user/*2/*1', '/a/b'); // '/user/b/a' + * buildUrlWithParams('/user/*1/*0', '/a/b'); // '/user/b/a' * buildUrlWithParams('/find?q=:x', '/search', '?x=cats'); // '/find?q=cats' * ``` */ @@ -194,36 +403,48 @@ export function buildUrlWithParams(toUrl: string, fromPath: string, fromSearch?: return toUrl; } - const [path] = toUrl.split(/[?#]/, 1); - let resolvedPath = path; - let suffix = toUrl.slice(path.length); - - if (path.includes('/*')) { - const fromPathParts = fromPath.split('/'); - resolvedPath = joinUrlParts( - ...path - .split('/') - .map((part, index) => - part[0] === '*' - ? (fromPathParts[part.length === 1 ? index : Number(part.slice(1))] ?? part) - : part, - ), - ); + const parsed = parseUrl(toUrl); + if (!parsed.hasDynamicSegments && (fromSearch === undefined || !parsed.hasDynamicQueryParams)) { + return toUrl; + } + + let resolvedPath: string; + if (parsed.hasDynamicSegments) { + const fromSegments = extractPathSegments(fromPath); + let builtPath = ''; + for (let i = 0; i < parsed.segments.length; i++) { + const segment = parsed.segments[i]; + const value = + segment.type === 'wildcard' + ? (fromSegments[segment.index ?? i] ?? serializePathSegment(segment)) + : serializePathSegment(segment); + if (value) { + builtPath += '/' + value; + } + } + resolvedPath = builtPath || '/'; + } else { + resolvedPath = serializePathSegments(parsed.segments); } - if (fromSearch !== undefined && suffix[0] === '?' && suffix.includes('=:')) { + let suffix = parsed.rawSuffix; + if (fromSearch !== undefined && parsed.hasDynamicQueryParams) { urlSerializer ??= new DefaultUrlSerializer(); - const toTree = urlSerializer.parse(suffix); + serializationTree ??= urlSerializer.parse(''); const actualParams = fromSearch ? urlSerializer.parse(fromSearch).queryParams : {}; + const resolvedQueryParams: Params = {}; - for (const [key, value] of Object.entries(toTree.queryParams)) { - if (typeof value === 'string' && value[0] === ':') { - toTree.queryParams[key] = actualParams[value.slice(1)]; - } + for (const param of parsed.queryParams) { + resolvedQueryParams[param.name] = + param.type === 'param' ? actualParams[param.param] : param.value; } - // `serialize(toTree)` returns `/?...#...` because `toTree.root` is empty. - suffix = urlSerializer.serialize(toTree).slice(1); + serializationTree.queryParams = resolvedQueryParams; + serializationTree.fragment = parsed.fragment; + // `serialize(serializationTree)` returns `/?...#...` because `serializationTree.root` is empty. + suffix = urlSerializer.serialize(serializationTree).slice(1); + serializationTree.queryParams = {}; + serializationTree.fragment = null; } return resolvedPath + suffix; diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index dc98403416b4..3762384b1ed5 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -517,7 +517,7 @@ describe('extractRoutesAndCreateRouteTree', () => { expect(errors).toHaveSize(0); expect(routeTree.toObject()).toEqual([ { route: '/user/7', renderMode: RenderMode.Prerender, redirectTo: '/profile/7' }, - { route: '/user/*', renderMode: RenderMode.Server, redirectTo: '/profile/*2' }, + { route: '/user/*', renderMode: RenderMode.Server, redirectTo: '/profile/*1' }, { route: '/profile/*', renderMode: RenderMode.Server }, ]); }); @@ -706,11 +706,11 @@ describe('extractRoutesAndCreateRouteTree', () => { { route: '/*/user/*', renderMode: RenderMode.Server, - redirectTo: '/*/profile/*3', + redirectTo: '/*/profile/*2', relativeRedirect: true, }, { route: '/*/profile/*', renderMode: RenderMode.Server }, - { route: '/search/*', renderMode: RenderMode.Server, redirectTo: '/find/*2' }, + { route: '/search/*', renderMode: RenderMode.Server, redirectTo: '/find/*1' }, ]); }); diff --git a/packages/angular/ssr/test/utils/url_spec.ts b/packages/angular/ssr/test/utils/url_spec.ts index 8e22edb7226f..0581bb64c516 100644 --- a/packages/angular/ssr/test/utils/url_spec.ts +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -11,6 +11,9 @@ import { addTrailingSlash, buildUrlWithParams, joinUrlParts, + parsePathSegments, + parseUrl, + serializePathSegments, stripIndexHtmlFromURL, stripLeadingSlash, stripMatrixParams, @@ -185,12 +188,12 @@ describe('URL Utils', () => { }); it('should replace positional placeholders with the segments at those positions', () => { - expect(buildUrlWithParams('/user/*2/*1', '/a/b')).toBe('/user/b/a'); + expect(buildUrlWithParams('/user/*1/*0', '/a/b')).toBe('/user/b/a'); }); it('should replace a positional placeholder followed by a query or fragment', () => { - expect(buildUrlWithParams('/user/*1?returnTo=/a//b/', '/a')).toBe('/user/a?returnTo=/a//b/'); - expect(buildUrlWithParams('/user/*1#top', '/a')).toBe('/user/a#top'); + expect(buildUrlWithParams('/user/*0?returnTo=/a//b/', '/a')).toBe('/user/a?returnTo=/a//b/'); + expect(buildUrlWithParams('/user/*0#top', '/a')).toBe('/user/a#top'); }); it('should throw an error if the toUrl does not start with "/"', () => { @@ -251,4 +254,38 @@ describe('URL Utils', () => { expect(stripMatrixParams('')).toBe(''); }); }); + + describe('parseUrl and serializePathSegments', () => { + it('should parse path segments and query parameters into discriminated unions', () => { + const parsed = parseUrl('/user/:id/*/details/*0/**?q=:term&q2=:a&q2=:b&static=1#section'); + + expect(parsed.isAbsolute).toBeTrue(); + expect(parsed.hasDynamicSegments).toBeTrue(); + expect(parsed.hasDynamicQueryParams).toBeTrue(); + expect(parsed.fragment).toBe('section'); + expect(parsed.segments).toEqual([ + { type: 'static', value: 'user' }, + { type: 'param', name: 'id' }, + { type: 'wildcard' }, + { type: 'static', value: 'details' }, + { type: 'wildcard', index: 0 }, + { type: 'static', value: '**' }, + ]); + expect(parsed.queryParams).toEqual([ + { type: 'param', name: 'q', param: 'term' }, + { type: 'static', name: 'q2', value: [':a', ':b'] }, + { type: 'static', name: 'static', value: '1' }, + ]); + expect(serializePathSegments(parsed.segments)).toBe('/user/:id/*/details/*0/**'); + }); + + it('should normalize leading, trailing, and consecutive slashes in parsePathSegments', () => { + expect(parsePathSegments('///a//b/:c//')).toEqual([ + { type: 'static', value: 'a' }, + { type: 'static', value: 'b' }, + { type: 'param', name: 'c' }, + ]); + expect(serializePathSegments([])).toBe('/'); + }); + }); });