Skip to content

Commit bcb9f3a

Browse files
committed
fix(@angular/build): handle APP_BASE_HREF correctly in prerendered routes
This commit resolves path stripping issues when `APP_BASE_HREF` does not align with the expected value. Closes #28775
1 parent 7eabe7d commit bcb9f3a

File tree

4 files changed

+178
-17
lines changed

4 files changed

+178
-17
lines changed

packages/angular/build/src/builders/application/execute-post-bundle.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ import {
2929
} from '../../utils/server-rendering/models';
3030
import { prerenderPages } from '../../utils/server-rendering/prerender';
3131
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
32-
import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options';
32+
import {
33+
getLocaleBaseHref,
34+
INDEX_HTML_CSR,
35+
INDEX_HTML_SERVER,
36+
NormalizedApplicationBuildOptions,
37+
} from './options';
3338
import { OutputMode } from './schema';
3439

3540
/**
@@ -40,6 +45,7 @@ import { OutputMode } from './schema';
4045
* @param initialFiles A map containing initial file information for the executed build.
4146
* @param locale A language locale to insert in the index.html.
4247
*/
48+
// eslint-disable-next-line max-lines-per-function
4349
export async function executePostBundleSteps(
4450
options: NormalizedApplicationBuildOptions,
4551
outputFiles: BuildOutputFile[],
@@ -73,6 +79,10 @@ export async function executePostBundleSteps(
7379
partialSSRBuild,
7480
} = options;
7581

82+
const baseHrefWithLocale = locale
83+
? (getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref)
84+
: options.baseHref;
85+
7686
// Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR).
7787
// NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering.
7888
// Additionally, when using prerendering or AppShell, the index HTML file may be regenerated.
@@ -85,7 +95,7 @@ export async function executePostBundleSteps(
8595
const { csrContent, ssrContent, errors, warnings } = await generateIndexHtml(
8696
initialFiles,
8797
outputFiles,
88-
options,
98+
{ ...options, baseHref: baseHrefWithLocale },
8999
locale,
90100
);
91101

@@ -215,7 +225,7 @@ export async function executePostBundleSteps(
215225
const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild(
216226
workspaceRoot,
217227
serviceWorker,
218-
baseHref,
228+
baseHrefWithLocale,
219229
options.indexHtmlOptions?.output,
220230
// Ensure additional files recently added are used
221231
[...outputFiles, ...additionalOutputFiles],

packages/angular/build/src/builders/application/i18n.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { maxWorkers } from '../../utils/environment-options';
1818
import { loadTranslations } from '../../utils/i18n-options';
1919
import { createTranslationLoader } from '../../utils/load-translations';
2020
import { executePostBundleSteps } from './execute-post-bundle';
21-
import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options';
21+
import { NormalizedApplicationBuildOptions } from './options';
2222

2323
/**
2424
* Inlines all active locales as specified by the application build options into all
@@ -70,20 +70,14 @@ export async function inlineI18n(
7070
inlineResult.errors.push(...localeInlineResult.errors);
7171
inlineResult.warnings.push(...localeInlineResult.warnings);
7272

73-
const baseHref =
74-
getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref;
75-
7673
const {
7774
errors,
7875
warnings,
7976
additionalAssets,
8077
additionalOutputFiles,
8178
prerenderedRoutes: generatedRoutes,
8279
} = await executePostBundleSteps(
83-
{
84-
...options,
85-
baseHref,
86-
},
80+
options,
8781
localeOutputFiles,
8882
executionResult.assetFiles,
8983
initialFiles,

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,11 @@ async function renderPages(
219219
const baseHrefWithLeadingSlash = addLeadingSlash(baseHref);
220220

221221
for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) {
222-
// Remove base href from file output path.
223-
const routeWithoutBaseHref = addLeadingSlash(
224-
route.slice(baseHrefWithLeadingSlash.length - 1),
225-
);
222+
// Remove the base href from the file output path.
223+
const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash)
224+
? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length - 1))
225+
: route;
226+
226227
const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html');
227228

228229
if (typeof redirectTo === 'string') {
@@ -336,11 +337,15 @@ async function getAllRoutes(
336337
}
337338

338339
function addLeadingSlash(value: string): string {
339-
return value.charAt(0) === '/' ? value : '/' + value;
340+
return value[0] === '/' ? value : '/' + value;
341+
}
342+
343+
function addTrailingSlash(url: string): string {
344+
return url[url.length - 1] === '/' ? url : `${url}/`;
340345
}
341346

342347
function removeLeadingSlash(value: string): string {
343-
return value.charAt(0) === '/' ? value.slice(1) : value;
348+
return value[0] === '/' ? value.slice(1) : value;
344349
}
345350

346351
/**
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { join } from 'node:path';
2+
import { existsSync } from 'node:fs';
3+
import assert from 'node:assert';
4+
import {
5+
expectFileNotToExist,
6+
expectFileToMatch,
7+
replaceInFile,
8+
writeFile,
9+
} from '../../../utils/fs';
10+
import { ng, noSilentNg, silentNg } from '../../../utils/process';
11+
import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages';
12+
import { useSha } from '../../../utils/project';
13+
import { getGlobalVariable } from '../../../utils/env';
14+
import { expectToFail } from '../../../utils/utils';
15+
import { setupI18nConfig } from '../../i18n/setup';
16+
17+
export default async function () {
18+
assert(
19+
getGlobalVariable('argv')['esbuild'],
20+
'This test should not be called in the Webpack suite.',
21+
);
22+
23+
// Setup project
24+
await setupI18nConfig();
25+
26+
// Forcibly remove in case another test doesn't clean itself up.
27+
await uninstallPackage('@angular/ssr');
28+
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
29+
await useSha();
30+
await installWorkspacePackages();
31+
32+
// Add routes
33+
await writeFile(
34+
'src/app/app.routes.ts',
35+
`
36+
import { Routes } from '@angular/router';
37+
import { HomeComponent } from './home/home.component';
38+
import { SsgComponent } from './ssg/ssg.component';
39+
import { SsgWithParamsComponent } from './ssg-with-params/ssg-with-params.component';
40+
41+
export const routes: Routes = [
42+
{
43+
path: '',
44+
component: HomeComponent,
45+
},
46+
{
47+
path: 'ssg',
48+
component: SsgComponent,
49+
},
50+
{
51+
path: 'ssg-redirect',
52+
redirectTo: 'ssg'
53+
},
54+
{
55+
path: 'ssg/:id',
56+
component: SsgWithParamsComponent,
57+
},
58+
{
59+
path: '**',
60+
component: HomeComponent,
61+
},
62+
];
63+
`,
64+
);
65+
66+
// Add server routing
67+
await writeFile(
68+
'src/app/app.routes.server.ts',
69+
`
70+
import { RenderMode, ServerRoute } from '@angular/ssr';
71+
72+
export const serverRoutes: ServerRoute[] = [
73+
{
74+
path: 'ssg/:id',
75+
renderMode: RenderMode.Prerender,
76+
getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}],
77+
},
78+
{
79+
path: '**',
80+
renderMode: RenderMode.Server,
81+
},
82+
];
83+
`,
84+
);
85+
86+
await writeFile(
87+
'src/app/app.config.ts',
88+
`
89+
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
90+
import { provideRouter } from '@angular/router';
91+
92+
import { routes } from './app.routes';
93+
import { provideClientHydration } from '@angular/platform-browser';
94+
import { APP_BASE_HREF } from '@angular/common';
95+
96+
export const appConfig: ApplicationConfig = {
97+
providers: [
98+
provideZoneChangeDetection({ eventCoalescing: true }),
99+
provideRouter(routes),
100+
provideClientHydration(),
101+
{
102+
provide: APP_BASE_HREF,
103+
useValue: '/',
104+
},
105+
],
106+
};
107+
`,
108+
);
109+
110+
// Generate components for the above routes
111+
const componentNames: string[] = ['home', 'ssg', 'ssg-with-params'];
112+
for (const componentName of componentNames) {
113+
await silentNg('generate', 'component', componentName);
114+
}
115+
116+
// Should error as above we set `RenderMode.Server`
117+
const { message: errorMessage } = await expectToFail(() =>
118+
noSilentNg('build', '--output-mode=static'),
119+
);
120+
assert.match(
121+
errorMessage,
122+
new RegExp(
123+
`Route '/' is configured with server render mode, but the build 'outputMode' is set to 'static'.`,
124+
),
125+
);
126+
127+
// Fix the error
128+
await replaceInFile('src/app/app.routes.server.ts', 'RenderMode.Server', 'RenderMode.Prerender');
129+
await noSilentNg('build', '--output-mode=static');
130+
131+
const expects: Record<string, string> = {
132+
'index.html': 'home works!',
133+
'ssg/index.html': 'ssg works!',
134+
'ssg/one/index.html': 'ssg-with-params works!',
135+
'ssg/two/index.html': 'ssg-with-params works!',
136+
// When static redirects as generated as meta tags.
137+
'ssg-redirect/index.html': '<meta http-equiv="refresh" content="0; url=/ssg">',
138+
};
139+
140+
for (const [filePath, fileMatch] of Object.entries(expects)) {
141+
await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch);
142+
}
143+
144+
// Check that server directory does not exist
145+
assert(
146+
!existsSync('dist/test-project/server'),
147+
'Server directory should not exist when output-mode is static',
148+
);
149+
150+
// Should not prerender the catch all
151+
await expectFileNotToExist(join('dist/test-project/browser/**/index.html'));
152+
}

0 commit comments

Comments
 (0)