From 7968780a704bf10334eed1bac44c59f4da9125ef Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:44:21 +0000 Subject: [PATCH] fix(@angular/build): resolve assets correctly during i18n prerendering In i18n static output builds, requests for assets made during prerendering (e.g., via `HttpClient`) include the locale's `baseHref`. However, the in-memory asset mapping used by the patched server-side `fetch` did not account for this `baseHref`, causing assets to fail to resolve. Closes #32713 --- .../src/utils/server-rendering/prerender.ts | 4 +- ...utes-output-mode-static-i18n-http-calls.ts | 137 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n-http-calls.ts diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 39d0f0934c92..d2f60744381a 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -96,7 +96,9 @@ export async function prerenderPages( const assetsReversed: Record = {}; for (const { source, destination } of assets) { - assetsReversed[addLeadingSlash(toPosixPath(destination))] = source; + // Assets are not stored with baseHref when using i18n, + // we append the base href so that requests are resolved correctly. + assetsReversed[joinUrlParts(baseHref, toPosixPath(destination))] = source; } // Get routes to prerender diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n-http-calls.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n-http-calls.ts new file mode 100644 index 000000000000..964c7827c23e --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n-http-calls.ts @@ -0,0 +1,137 @@ +import assert, { match } from 'node:assert'; +import { join } from 'node:path'; +import { readFile, writeMultipleFiles } from '../../../utils/fs'; +import { ng, noSilentNg, silentNg } from '../../../utils/process'; +import { getGlobalVariable } from '../../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Add asset + 'public/media.json': JSON.stringify({ dataFromAssets: true }), + // Update component to do an HTTP call to asset and API. + 'src/app/app.ts': ` + import { ChangeDetectorRef, Component, inject } from '@angular/core'; + import { JsonPipe } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + import { HttpClient } from '@angular/common/http'; + + @Component({ + selector: 'app-root', + imports: [JsonPipe, RouterOutlet], + template: \` +

{{ assetsData | json }}

+

{{ apiData | json }}

+ + \`, + }) + export class App { + assetsData: any; + apiData: any; + private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + const http = inject(HttpClient); + + http.get('media.json').toPromise().then((d) => { + this.assetsData = d; + this.cdr.markForCheck(); + }); + + http.get('/api').toPromise().then((d) => { + this.apiData = d; + this.cdr.markForCheck(); + }); + } + } + `, + // Add http client and route + 'src/app/app.config.ts': ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import { Home } from './home/home'; + import { provideClientHydration } from '@angular/platform-browser'; + import { provideHttpClient, withFetch } from '@angular/common/http'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideRouter([{ + path: 'home', + component: Home, + }]), + provideClientHydration(), + provideHttpClient(withFetch()), + ], + }; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { join } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const browserDistFolder = join(import.meta.dirname, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.get('/api', (req, res) => { + res.json({ dataFromAPI: true }) + }); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.use((req, res, next) => { + angularNodeAppEngine.handle(req) + .then((response) => response ? writeResponseToNodeResponse(response, res) : next()) + .catch(next); + }); + return server; + } + + const server = app(); + + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, (error) => { + if (error) { + throw error; + } + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + await noSilentNg('build', '--output-mode=static'); + + for (const { lang, outputPath } of langTranslations) { + const contents = await readFile(join(outputPath, 'home/index.html')); + match(contents, /

{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/); + match(contents, /

{[\S\s]*"dataFromAPI":[\s\S]*true[\S\s]*}<\/p>/); + match(contents, new RegExp(``)); + } +}