Skip to content

Commit 9ce8e3d

Browse files
committed
fix(@angular/ssr): handle nested redirects not explicitly defined in router config
This commit ensures proper handling of nested redirects that are implicitly structured but not explicitly defined in the router configuration. Closes #28903
1 parent 148af6e commit 9ce8e3d

File tree

7 files changed

+253
-33
lines changed

7 files changed

+253
-33
lines changed

packages/angular/ssr/src/app.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ import { sha256 } from './utils/crypto';
2424
import { InlineCriticalCssProcessor } from './utils/inline-critical-css';
2525
import { LRUCache } from './utils/lru-cache';
2626
import { AngularBootstrap, renderAngular } from './utils/ng';
27-
import { joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash } from './utils/url';
27+
import {
28+
constructUrlWithPlaceholders,
29+
joinUrlParts,
30+
stripIndexHtmlFromURL,
31+
stripLeadingSlash,
32+
} from './utils/url';
2833

2934
/**
3035
* Maximum number of critical CSS entries the cache can store.
@@ -160,11 +165,14 @@ export class AngularServerApp {
160165

161166
const { redirectTo, status, renderMode } = matchedRoute;
162167
if (redirectTo !== undefined) {
163-
// Note: The status code is validated during route extraction.
164-
// 302 Found is used by default for redirections
165-
// See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
166-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
167-
return Response.redirect(new URL(redirectTo, url), (status as any) ?? 302);
168+
return Response.redirect(
169+
constructUrlWithPlaceholders(redirectTo, url),
170+
// Note: The status code is validated during route extraction.
171+
// 302 Found is used by default for redirections
172+
// See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
173+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
174+
(status as any) ?? 302,
175+
);
168176
}
169177

170178
if (renderMode === RenderMode.Prerender) {
@@ -241,17 +249,8 @@ export class AngularServerApp {
241249
matchedRoute: RouteTreeNodeMetadata,
242250
requestContext?: unknown,
243251
): Promise<Response | null> {
244-
const { redirectTo, status } = matchedRoute;
245-
246-
if (redirectTo !== undefined) {
247-
// Note: The status code is validated during route extraction.
248-
// 302 Found is used by default for redirections
249-
// See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
250-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
251-
return Response.redirect(new URL(redirectTo, new URL(request.url)), (status as any) ?? 302);
252-
}
252+
const { renderMode, headers, status } = matchedRoute;
253253

254-
const { renderMode, headers } = matchedRoute;
255254
if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) {
256255
return null;
257256
}

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

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ServerAssets } from '../assets';
2121
import { Console } from '../console';
2222
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
2323
import { AngularBootstrap, isNgModule } from '../utils/ng';
24-
import { joinUrlParts, stripLeadingSlash } from '../utils/url';
24+
import { addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../utils/url';
2525
import {
2626
PrerenderFallback,
2727
RenderMode,
@@ -146,31 +146,36 @@ async function* traverseRoutesConfig(options: {
146146
const metadata: ServerConfigRouteTreeNodeMetadata = {
147147
renderMode: RenderMode.Prerender,
148148
...matchedMetaData,
149-
route: currentRoutePath,
149+
// Match Angular router behavior
150+
// ['one', 'two', ''] -> 'one/two/'
151+
// ['one', 'two', 'three'] -> 'one/two/three'
152+
route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath,
150153
};
151154

152155
delete metadata.presentInClientRouter;
153156

154-
// Handle redirects
155-
if (typeof redirectTo === 'string') {
156-
const redirectToResolved = resolveRedirectTo(currentRoutePath, redirectTo);
157+
if (metadata.renderMode === RenderMode.Prerender) {
158+
// Handle SSG routes
159+
yield* handleSSGRoute(
160+
typeof redirectTo === 'string' ? redirectTo : undefined,
161+
metadata,
162+
parentInjector,
163+
invokeGetPrerenderParams,
164+
includePrerenderFallbackRoutes,
165+
);
166+
} else if (typeof redirectTo === 'string') {
167+
// Handle redirects
157168
if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) {
158169
yield {
159170
error:
160171
`The '${metadata.status}' status code is not a valid redirect response code. ` +
161172
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
162173
};
174+
163175
continue;
164176
}
165-
yield { ...metadata, redirectTo: redirectToResolved };
166-
} else if (metadata.renderMode === RenderMode.Prerender) {
167-
// Handle SSG routes
168-
yield* handleSSGRoute(
169-
metadata,
170-
parentInjector,
171-
invokeGetPrerenderParams,
172-
includePrerenderFallbackRoutes,
173-
);
177+
178+
yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo) };
174179
} else {
175180
yield metadata;
176181
}
@@ -214,13 +219,15 @@ async function* traverseRoutesConfig(options: {
214219
* Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding
215220
* all parameterized paths, returning any errors encountered.
216221
*
222+
* @param redirectTo - Optional path to redirect to, if specified.
217223
* @param metadata - The metadata associated with the route tree node.
218224
* @param parentInjector - The dependency injection container for the parent route.
219225
* @param invokeGetPrerenderParams - A flag indicating whether to invoke the `getPrerenderParams` function.
220226
* @param includePrerenderFallbackRoutes - A flag indicating whether to include fallback routes in the result.
221227
* @returns An async iterable iterator that yields route tree node metadata for each SSG path or errors.
222228
*/
223229
async function* handleSSGRoute(
230+
redirectTo: string | undefined,
224231
metadata: ServerConfigRouteTreeNodeMetadata,
225232
parentInjector: Injector,
226233
invokeGetPrerenderParams: boolean,
@@ -239,6 +246,10 @@ async function* handleSSGRoute(
239246
delete meta['getPrerenderParams'];
240247
}
241248

249+
if (redirectTo !== undefined) {
250+
meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo);
251+
}
252+
242253
if (!URL_PARAMETER_REGEXP.test(currentRoutePath)) {
243254
// Route has no parameters
244255
yield {
@@ -279,7 +290,12 @@ async function* handleSSGRoute(
279290
return value;
280291
});
281292

282-
yield { ...meta, route: routeWithResolvedParams };
293+
const result = { ...meta, route: routeWithResolvedParams };
294+
if (redirectTo !== undefined) {
295+
result.redirectTo = resolveRedirectTo(routeWithResolvedParams, redirectTo);
296+
}
297+
298+
yield result;
283299
}
284300
} catch (error) {
285301
yield { error: `${(error as Error).message}` };
@@ -456,7 +472,6 @@ export async function getRoutesFromAngularRouterConfig(
456472
includePrerenderFallbackRoutes,
457473
});
458474

459-
let seenAppShellRoute: string | undefined;
460475
for await (const result of traverseRoutes) {
461476
if ('error' in result) {
462477
errors.push(result.error);

packages/angular/ssr/src/routes/router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { AngularAppManifest } from '../manifest';
10-
import { stripIndexHtmlFromURL } from '../utils/url';
10+
import { joinUrlParts, stripIndexHtmlFromURL } from '../utils/url';
1111
import { extractRoutesAndCreateRouteTree } from './ng-routes';
1212
import { RouteTree, RouteTreeNodeMetadata } from './route-tree';
1313

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ export function addLeadingSlash(url: string): string {
6161
return url[0] === '/' ? url : `/${url}`;
6262
}
6363

64+
/**
65+
* Adds a trailing slash to a URL if it does not already have one.
66+
*
67+
* @param url - The URL string to which the trailing slash will be added.
68+
* @returns The URL string with a trailing slash.
69+
*
70+
* @example
71+
* ```js
72+
* addTrailingSlash('path'); // 'path/'
73+
* addTrailingSlash('path/'); // 'path/'
74+
* ```
75+
*/
76+
export function addTrailingSlash(url: string): string {
77+
// Check if the URL already end with a slash
78+
return url[url.length - 1] === '/' ? url : `${url}/`;
79+
}
80+
6481
/**
6582
* Joins URL parts into a single URL string.
6683
*
@@ -128,3 +145,49 @@ export function stripIndexHtmlFromURL(url: URL): URL {
128145

129146
return url;
130147
}
148+
149+
/**
150+
* Resolves dynamic placeholders in a URL template by mapping them to corresponding segments
151+
* from a base URL's path.
152+
*
153+
* This function processes the `urlTemplate` string, replacing any placeholders (e.g., `:param`)
154+
* with segments from the `baseUrl`. If the `urlTemplate` contains no placeholders, it resolves
155+
* it as a relative URL against the `baseUrl`.
156+
*
157+
* @param urlTemplate - A string representing the URL template, which may include dynamic placeholders
158+
* (e.g., `/:param`) to be replaced with segments from the `baseUrl`'s path.
159+
* @param baseUrl - The base URL used as a reference for resolving relative paths and dynamic segments
160+
* in the `urlTemplate`. The base URL's pathname is split into parts to map to placeholders.
161+
* @returns A fully constructed `URL` object with placeholders replaced, or the resolved relative URL
162+
* if no placeholders are present.
163+
*
164+
* @example
165+
* ```typescript
166+
* const resolvedUrl = constructUrlWithPlaceholders('/:id/details', new URL('http://example.com/user/123'));
167+
* console.log(resolvedUrl.toString()); // Outputs: 'http://example.com/123/details'
168+
169+
* const staticUrl = constructUrlWithPlaceholders('/static/path', new URL('http://example.com/base'));
170+
* console.log(staticUrl.toString()); // Outputs: 'http://example.com/static/path'
171+
* ```
172+
*/
173+
export function constructUrlWithPlaceholders(urlTemplate: string, baseUrl: URL): URL {
174+
if (!urlTemplate.includes('/:')) {
175+
return new URL(urlTemplate, baseUrl);
176+
}
177+
178+
if (urlTemplate[0] !== '/') {
179+
throw new Error(
180+
`Invalid urlTemplate: The template must start with a '/' to be a relative path. Received: '${urlTemplate}'`,
181+
);
182+
}
183+
184+
const templateParts = urlTemplate.split('/');
185+
const basePathParts = baseUrl.pathname.split('/');
186+
187+
// Replace placeholders in the template with corresponding segments from the base path.
188+
const resolvedParts = templateParts.map((part, index) =>
189+
part[0] === ':' ? (basePathParts[index] ?? part) : part,
190+
);
191+
192+
return new URL(joinUrlParts(...resolvedParts), baseUrl);
193+
}

packages/angular/ssr/test/app_spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('AngularServerApp', () => {
3838
{ path: 'page-with-status', component: HomeComponent },
3939
{ path: 'redirect', redirectTo: 'home' },
4040
{ path: 'redirect/relative', redirectTo: 'home' },
41+
{ path: 'redirect/:param/relative', redirectTo: 'home' },
4142
{ path: 'redirect/absolute', redirectTo: '/home' },
4243
],
4344
[
@@ -117,6 +118,12 @@ describe('AngularServerApp', () => {
117118
expect(response?.status).toBe(302);
118119
});
119120

121+
it('should correctly handle relative nested redirects with parameter', async () => {
122+
const response = await app.handle(new Request('http://localhost/redirect/param/relative'));
123+
expect(response?.headers.get('location')).toContain('http://localhost/redirect/param/home');
124+
expect(response?.status).toBe(302);
125+
});
126+
120127
it('should correctly handle absolute nested redirects', async () => {
121128
const response = await app.handle(new Request('http://localhost/redirect/absolute'));
122129
expect(response?.headers.get('location')).toContain('http://localhost/home');

packages/angular/ssr/test/routes/ng-routes_spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,92 @@ describe('extractRoutesAndCreateRouteTree', () => {
165165
},
166166
]);
167167
});
168+
169+
it('should extract nested redirects that are not explicitly defined.', async () => {
170+
setAngularAppTestingManifest(
171+
[
172+
{
173+
path: '',
174+
pathMatch: 'full',
175+
redirectTo: 'some',
176+
},
177+
{
178+
path: ':param',
179+
children: [
180+
{
181+
path: '',
182+
pathMatch: 'full',
183+
redirectTo: 'thing',
184+
},
185+
{
186+
path: 'thing',
187+
component: DummyComponent,
188+
},
189+
],
190+
},
191+
],
192+
[
193+
{
194+
path: ':param',
195+
renderMode: RenderMode.Prerender,
196+
async getPrerenderParams() {
197+
return [{ param: 'some' }];
198+
},
199+
},
200+
{ path: '**', renderMode: RenderMode.Prerender },
201+
],
202+
);
203+
204+
const { routeTree, errors } = await extractRoutesAndCreateRouteTree(
205+
url,
206+
/** manifest */ undefined,
207+
/** invokeGetPrerenderParams */ true,
208+
/** includePrerenderFallbackRoutes */ true,
209+
);
210+
expect(errors).toHaveSize(0);
211+
expect(routeTree.toObject()).toEqual([
212+
{ route: '/', renderMode: RenderMode.Prerender, redirectTo: '/some' },
213+
{ route: '/some', renderMode: RenderMode.Prerender, redirectTo: '/some/thing' },
214+
{ route: '/some/thing', renderMode: RenderMode.Prerender },
215+
{ redirectTo: '/:param/thing', route: '/:param', renderMode: RenderMode.Server },
216+
{ route: '/:param/thing', renderMode: RenderMode.Server },
217+
]);
218+
});
219+
});
220+
221+
it('should extract nested redirects that are not explicitly defined.', async () => {
222+
setAngularAppTestingManifest(
223+
[
224+
{
225+
path: '',
226+
pathMatch: 'full',
227+
redirectTo: 'some',
228+
},
229+
{
230+
path: ':param',
231+
children: [
232+
{
233+
path: '',
234+
pathMatch: 'full',
235+
redirectTo: 'thing',
236+
},
237+
{
238+
path: 'thing',
239+
component: DummyComponent,
240+
},
241+
],
242+
},
243+
],
244+
[{ path: '**', renderMode: RenderMode.Server }],
245+
);
246+
247+
const { routeTree, errors } = await extractRoutesAndCreateRouteTree(url);
248+
expect(errors).toHaveSize(0);
249+
expect(routeTree.toObject()).toEqual([
250+
{ route: '/', renderMode: RenderMode.Server, redirectTo: '/some' },
251+
{ route: '/:param', renderMode: RenderMode.Server, redirectTo: '/:param/thing' },
252+
{ route: '/:param/thing', renderMode: RenderMode.Server },
253+
]);
168254
});
169255

170256
it('should not resolve parameterized routes for SSG when `invokeGetPrerenderParams` is false', async () => {

0 commit comments

Comments
 (0)