From 08e07e338edd799400e18cd62cd131b6d408f4cf Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 4 Nov 2025 09:48:11 +0000 Subject: [PATCH 1/4] fix(@angular/ssr): improve locale handling in app-engine Refactor app-engine to correctly handle single locale configurations and update manifest type definitions. Add new test cases for localized apps with a single locale. closes #31675 (cherry picked from commit 30efc56333f56a0feda418cee57eccbfbaf46936) --- packages/angular/ssr/src/app-engine.ts | 5 +- packages/angular/ssr/src/manifest.ts | 2 +- packages/angular/ssr/test/app-engine_spec.ts | 60 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 0ce5d23c30d1..dd204a4b595f 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -191,9 +191,10 @@ export class AngularAppEngine { * @returns A promise that resolves to the entry point exports or `undefined` if not found. */ private getEntryPointExportsForUrl(url: URL): Promise | undefined { - const { basePath } = this.manifest; + const { basePath, supportedLocales } = this.manifest; + if (this.supportedLocales.length === 1) { - return this.getEntryPointExports(''); + return this.getEntryPointExports(supportedLocales[this.supportedLocales[0]]); } const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath); diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 8fc415546033..0de603bba104 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -73,7 +73,7 @@ export interface AngularAppEngineManifest { * - `key`: The locale identifier (e.g., 'en', 'fr'). * - `value`: The url segment associated with that locale. */ - readonly supportedLocales: Readonly>; + readonly supportedLocales: Readonly>; } /** diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index c8ebbc4446ea..76b68f6d4a82 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -160,6 +160,66 @@ describe('AngularAppEngine', () => { }); }); + describe('Localized app with single locale', () => { + beforeAll(() => { + setAngularAppEngineManifest({ + entryPoints: { + it: createEntryPoint('it'), + }, + supportedLocales: { 'it': 'it' }, + basePath: '/', + }); + + appEngine = new AngularAppEngine(); + }); + + describe('handle', () => { + it('should return null for requests to unknown pages', async () => { + const request = new Request('https://example.com/unknown/page'); + const response = await appEngine.handle(request); + expect(response).toBeNull(); + }); + + it('should return a rendered page with correct locale', async () => { + const request = new Request('https://example.com/it/ssr'); + const response = await appEngine.handle(request); + expect(await response?.text()).toContain('SSR works IT'); + }); + + it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => { + const request = new Request('https://example.com/it/ssr/index.html'); + const response = await appEngine.handle(request); + expect(await response?.text()).toContain('SSR works IT'); + expect(response?.headers?.get('Content-Language')).toBe('it'); + }); + + it('should return a serve prerendered page with correct locale', async () => { + const request = new Request('https://example.com/it/ssg'); + const response = await appEngine.handle(request); + expect(await response?.text()).toContain('SSG works IT'); + expect(response?.headers?.get('Content-Language')).toBe('it'); + }); + + it('should correctly serve the prerendered content when the URL ends with "index.html" with correct locale', async () => { + const request = new Request('https://example.com/it/ssg/index.html'); + const response = await appEngine.handle(request); + expect(await response?.text()).toContain('SSG works IT'); + }); + + it('should return null for requests to unknown pages in a locale', async () => { + const request = new Request('https://example.com/it/unknown/page'); + const response = await appEngine.handle(request); + expect(response).toBeNull(); + }); + + it('should return null for requests to file-like resources in a locale', async () => { + const request = new Request('https://example.com/it/logo.png'); + const response = await appEngine.handle(request); + expect(response).toBeNull(); + }); + }); + }); + describe('Non-localized app', () => { beforeAll(() => { @Component({ From 683697ebc5e129d2b17bded9e4ff318d598e0bd3 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 4 Nov 2025 09:56:08 +0000 Subject: [PATCH 2/4] fix(@angular/ssr): improve route matching for wildcard routes Enhance the route traversal logic to correctly identify and process wildcard route matchers (e.g., '**'). This ensures that routes with matchers are properly marked as present in the client router and handled according to their render mode. closes #31666 (cherry picked from commit 4eb22bc2e6bb90aa8d9d617e864e7976da3feb5a) --- packages/angular/ssr/src/routes/ng-routes.ts | 19 ++++++--- .../angular/ssr/test/routes/ng-routes_spec.ts | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index a7ca0d137d88..8a91e47c0420 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -256,15 +256,22 @@ async function* traverseRoutesConfig(options: { const currentRoutePath = joinUrlParts(parentRoute, path); if (matcher && serverConfigRouteTree) { - let foundMatch = false; + const matches: (RouteTreeNodeMetadata & ServerConfigRouteTreeAdditionalMetadata)[] = []; for (const matchedMetaData of serverConfigRouteTree.traverse()) { - if (!matchedMetaData.route.startsWith(currentRoutePath)) { - continue; + if (matchedMetaData.route.startsWith(currentRoutePath)) { + matches.push(matchedMetaData); } + } - foundMatch = true; - matchedMetaData.presentInClientRouter = true; + if (!matches.length) { + const matchedMetaData = serverConfigRouteTree.match(currentRoutePath); + if (matchedMetaData) { + matches.push(matchedMetaData); + } + } + for (const matchedMetaData of matches) { + matchedMetaData.presentInClientRouter = true; if (matchedMetaData.renderMode === RenderMode.Prerender) { yield { error: @@ -287,7 +294,7 @@ async function* traverseRoutesConfig(options: { }); } - if (!foundMatch) { + if (!matches.length) { yield { error: `The route '${stripLeadingSlash(currentRoutePath)}' has a defined matcher but does not ` + diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index d9fae4bcfb41..4c961aac821b 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -429,6 +429,45 @@ describe('extractRoutesAndCreateRouteTree', () => { ]); }); + it('should extract routes with a route level matcher captured by "**"', async () => { + setAngularAppTestingManifest( + [ + { + path: '', + component: DummyComponent, + }, + { + path: 'list', + component: DummyComponent, + }, + { + path: 'product', + component: DummyComponent, + children: [ + { + matcher: () => null, + component: DummyComponent, + }, + ], + }, + ], + [ + { path: 'list', renderMode: RenderMode.Client }, + { path: '', renderMode: RenderMode.Client }, + { path: '**', renderMode: RenderMode.Server }, + ], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url }); + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { route: '/', renderMode: RenderMode.Client }, + { route: '/list', renderMode: RenderMode.Client }, + { route: '/product', renderMode: RenderMode.Server }, + { route: '/**', renderMode: RenderMode.Server }, + ]); + }); + it('should extract nested redirects that are not explicitly defined.', async () => { setAngularAppTestingManifest( [ From bddf211f26d26ccc69b2f1350c367cb55813ac53 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 4 Nov 2025 16:31:28 +0100 Subject: [PATCH 3/4] docs: clarify `outputMode` description in application schema Updates the description for the property in the application builder's schema. This change clarifies that both 'static' and 'server' modes generate build artifacts, and specifies that the 'server' mode is required for applications using hybrid rendering or APIs. (cherry picked from commit 1c4af8d3d8806b8374d5035736de41faa5b1a955) --- packages/angular/build/src/builders/application/schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index c0a0f98313aa..8db4e6145b3f 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -611,7 +611,7 @@ }, "outputMode": { "type": "string", - "description": "Defines the build output target. 'static': Generates a static site for deployment on any static hosting service. 'server': Produces an application designed for deployment on a server that supports server-side rendering (SSR).", + "description": "Defines the type of build output artifact. 'static': Generates a static site build artifact for deployment on any static hosting service. 'server': Generates a server application build artifact, required for applications using hybrid rendering or APIs.", "enum": ["static", "server"] } }, From 2c0cafa9c51ff82674cffedbd09a273cff34a835 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:40:46 +0000 Subject: [PATCH 4/4] release: cut the v20.3.9 release --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7bbfbaab97f..4c7ac77303ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ + + +# 20.3.9 (2025-11-05) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [08e07e338](https://github.com/angular/angular-cli/commit/08e07e338edd799400e18cd62cd131b6d408f4cf) | fix | improve locale handling in app-engine | +| [683697ebc](https://github.com/angular/angular-cli/commit/683697ebc5e129d2b17bded9e4ff318d598e0bd3) | fix | improve route matching for wildcard routes | + + + # 20.3.8 (2025-10-29) diff --git a/package.json b/package.json index c88f1c2927f7..0d7040ce3ab3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/devkit-repo", - "version": "20.3.8", + "version": "20.3.9", "private": true, "description": "Software Development Kit for Angular", "keywords": [