Skip to content

Commit 4ca6164

Browse files
alan-agius4clydin
authored andcommitted
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 angular#32713 (cherry picked from commit f30f890)
1 parent 8dec0c6 commit 4ca6164

File tree

2 files changed

+140
-1
lines changed

2 files changed

+140
-1
lines changed

packages/angular/build/src/utils/server-rendering/prerender.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ export async function prerenderPages(
9696

9797
const assetsReversed: Record</** Destination */ string, /** Source */ string> = {};
9898
for (const { source, destination } of assets) {
99-
assetsReversed[addLeadingSlash(toPosixPath(destination))] = source;
99+
// Assets are not stored with baseHref when using i18n,
100+
// we append the base href so that requests are resolved correctly.
101+
assetsReversed[joinUrlParts(baseHref, toPosixPath(destination))] = source;
100102
}
101103

102104
// Get routes to prerender
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import assert, { match } from 'node:assert';
2+
import { join } from 'node:path';
3+
import { readFile, writeMultipleFiles } from '../../../utils/fs';
4+
import { ng, noSilentNg, silentNg } from '../../../utils/process';
5+
import { getGlobalVariable } from '../../../utils/env';
6+
import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages';
7+
import { useSha } from '../../../utils/project';
8+
import { langTranslations, setupI18nConfig } from '../../i18n/setup';
9+
10+
export default async function () {
11+
assert(
12+
getGlobalVariable('argv')['esbuild'],
13+
'This test should not be called in the Webpack suite.',
14+
);
15+
16+
// Setup project
17+
await setupI18nConfig();
18+
19+
// Forcibly remove in case another test doesn't clean itself up.
20+
await uninstallPackage('@angular/ssr');
21+
await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install');
22+
await useSha();
23+
await installWorkspacePackages();
24+
25+
await writeMultipleFiles({
26+
// Add asset
27+
'public/media.json': JSON.stringify({ dataFromAssets: true }),
28+
// Update component to do an HTTP call to asset and API.
29+
'src/app/app.ts': `
30+
import { ChangeDetectorRef, Component, inject } from '@angular/core';
31+
import { JsonPipe } from '@angular/common';
32+
import { RouterOutlet } from '@angular/router';
33+
import { HttpClient } from '@angular/common/http';
34+
35+
@Component({
36+
selector: 'app-root',
37+
imports: [JsonPipe, RouterOutlet],
38+
template: \`
39+
<p>{{ assetsData | json }}</p>
40+
<p>{{ apiData | json }}</p>
41+
<router-outlet></router-outlet>
42+
\`,
43+
})
44+
export class App {
45+
assetsData: any;
46+
apiData: any;
47+
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
48+
49+
constructor() {
50+
const http = inject(HttpClient);
51+
52+
http.get('media.json').toPromise().then((d) => {
53+
this.assetsData = d;
54+
this.cdr.markForCheck();
55+
});
56+
57+
http.get('/api').toPromise().then((d) => {
58+
this.apiData = d;
59+
this.cdr.markForCheck();
60+
});
61+
}
62+
}
63+
`,
64+
// Add http client and route
65+
'src/app/app.config.ts': `
66+
import { ApplicationConfig } from '@angular/core';
67+
import { provideRouter } from '@angular/router';
68+
69+
import { Home } from './home/home';
70+
import { provideClientHydration } from '@angular/platform-browser';
71+
import { provideHttpClient, withFetch } from '@angular/common/http';
72+
73+
export const appConfig: ApplicationConfig = {
74+
providers: [
75+
provideRouter([{
76+
path: 'home',
77+
component: Home,
78+
}]),
79+
provideClientHydration(),
80+
provideHttpClient(withFetch()),
81+
],
82+
};
83+
`,
84+
'src/server.ts': `
85+
import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node';
86+
import express from 'express';
87+
import { join } from 'node:path';
88+
89+
export function app(): express.Express {
90+
const server = express();
91+
const browserDistFolder = join(import.meta.dirname, '../browser');
92+
const angularNodeAppEngine = new AngularNodeAppEngine();
93+
94+
server.get('/api', (req, res) => {
95+
res.json({ dataFromAPI: true })
96+
});
97+
98+
server.use(express.static(browserDistFolder, {
99+
maxAge: '1y',
100+
index: 'index.html'
101+
}));
102+
103+
server.use((req, res, next) => {
104+
angularNodeAppEngine.handle(req)
105+
.then((response) => response ? writeResponseToNodeResponse(response, res) : next())
106+
.catch(next);
107+
});
108+
return server;
109+
}
110+
111+
const server = app();
112+
113+
if (isMainModule(import.meta.url)) {
114+
const port = process.env['PORT'] || 4000;
115+
server.listen(port, (error) => {
116+
if (error) {
117+
throw error;
118+
}
119+
console.log(\`Node Express server listening on http://localhost:\${port}\`);
120+
});
121+
}
122+
123+
export const reqHandler = createNodeRequestHandler(server);
124+
`,
125+
});
126+
127+
await silentNg('generate', 'component', 'home');
128+
129+
await noSilentNg('build', '--output-mode=static');
130+
131+
for (const { lang, outputPath } of langTranslations) {
132+
const contents = await readFile(join(outputPath, 'home/index.html'));
133+
match(contents, /<p>{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/);
134+
match(contents, /<p>{[\S\s]*"dataFromAPI":[\s\S]*true[\S\s]*}<\/p>/);
135+
match(contents, new RegExp(`<base href="\\/${lang}\\/">`));
136+
}
137+
}

0 commit comments

Comments
 (0)