From 1dadd3df2863fdd6031193b91c715d956c02581a Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:15:02 +0000 Subject: [PATCH] fix(@angular/build): fail build and exclude routes when prerendering fails When a prerendered route failed to render (such as when a component throws during route activation), the render worker returned null content which was silently skipped. Consequently, no HTML file was written, but the build still reported the route in prerender statistics, included it in prerendered-routes.json, and exited with code 0. Now: - The render worker throws an error if content is null ('The content returned was empty.'). - Prerendering records the error so the build fails with a non-zero exit code. - Prerendered routes recorded for manifest and statistics are derived strictly from routes that produced output files. Closes #33965 --- .../application/execute-post-bundle.ts | 13 ++-- .../src/utils/server-rendering/prerender.ts | 66 +++++++++++------ .../utils/server-rendering/render-worker.ts | 20 ++++-- .../build/prerender/error-component-render.ts | 72 +++++++++++++++++++ 4 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 tests/e2e/tests/build/prerender/error-component-render.ts diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index cf76a14d9030..56098d9e2a38 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -157,7 +157,13 @@ export async function executePostBundleSteps( 'The "index" option is required when using the "ssg" or "appShell" options.', ); - const { output, warnings, errors, serializableRouteTreeNode } = await prerenderPages( + const { + output, + warnings, + errors, + serializableRouteTreeNode, + prerenderedRoutes: generatedPrerenderedRoutes, + } = await prerenderPages( workspaceRoot, baseHref, appShellOptions, @@ -171,6 +177,7 @@ export async function executePostBundleSteps( allErrors.push(...errors); allWarnings.push(...warnings); + Object.assign(prerenderedRoutes, generatedPrerenderedRoutes); const indexHasBeenPrerendered = output[indexHtmlOptions.output]; for (const [path, { content, appShellRoute }] of Object.entries(output)) { @@ -195,10 +202,6 @@ export async function executePostBundleSteps( const serializableRouteTreeNodeForManifest: WritableSerializableRouteTreeNode = []; for (const metadata of serializableRouteTreeNode) { serializableRouteTreeNodeForManifest.push(metadata); - - if (metadata.renderMode === RouteRenderMode.Prerender && !metadata.route.includes('*')) { - prerenderedRoutes[metadata.route] = { headers: metadata.headers }; - } } if (outputMode === OutputMode.Server) { diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 37e29f02385b..98502c63587d 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -10,7 +10,10 @@ import { readFile } from 'node:fs/promises'; import { extname, posix } from 'node:path'; import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { OutputMode } from '../../builders/application/schema'; -import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; +import { + BuildOutputAsset, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; import { assertIsError } from '../error'; import { toPosixPath } from '../path'; @@ -65,6 +68,7 @@ export async function prerenderPages( output: PrerenderOutput; warnings: string[]; errors: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; serializableRouteTreeNode: SerializableRouteTreeNode; }> { const rawOutputFiles: Record = {}; @@ -167,6 +171,7 @@ export async function prerenderPages( errors, warnings, output: {}, + prerenderedRoutes: {}, serializableRouteTreeNode, }; } @@ -200,10 +205,22 @@ export async function prerenderPages( errors.push(...renderingErrors); + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + + for (const metadata of serializableRouteTreeNodeForPrerender) { + const outPath = getRouteOutPath(metadata.route, baseHrefPathnameWithLeadingSlash); + + if (output[outPath]) { + prerenderedRoutes[metadata.route] = { headers: metadata.headers }; + } + } + return { errors, warnings, output, + prerenderedRoutes, serializableRouteTreeNode, }; } @@ -227,22 +244,15 @@ async function renderPages( const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; const appShellRouteWithoutBaseHref = appShellRoute - ? addTrailingSlash(appShellRoute).startsWith(baseHrefPathnameWithLeadingSlash) - ? addLeadingSlash(appShellRoute.slice(baseHrefPathnameWithLeadingSlash.length)) - : addLeadingSlash(appShellRoute) + ? addLeadingSlash(getRouteWithoutBaseHref(appShellRoute, baseHrefPathnameWithLeadingSlash)) : undefined; const routesToRender: { route: string; outPath: string; isAppShell: boolean }[] = []; for (const { route, redirectTo } of serializableRouteTreeNode) { // Remove the base href from the file output path. - const routeWithoutBaseHref = addTrailingSlash(route).startsWith( - baseHrefPathnameWithLeadingSlash, - ) - ? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length)) - : route; - - const outPath = stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathnameWithLeadingSlash); + const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash); if (typeof redirectTo === 'string') { output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; @@ -305,20 +315,20 @@ async function renderPages( const renderBatchPromise: Promise = renderWorker.run(urls); const batchResultPromise = renderBatchPromise .then((results) => { - for (const { url, content, error } of results) { - if (error) { - errors.push(`An error occurred while prerendering route '${url}'.\n\n${error}`); + for (const result of results) { + if ('error' in result) { + errors.push( + `An error occurred while prerendering route '${result.url}'.\n\n${result.error}`, + ); continue; } - if (content !== null) { - const routeInfo = routeOutPathMap.get(url); - if (routeInfo) { - output[routeInfo.outPath] = { - content, - appShellRoute: routeInfo.isAppShell, - }; - } + const routeInfo = routeOutPathMap.get(result.url); + if (routeInfo) { + output[routeInfo.outPath] = { + content: result.content, + appShellRoute: routeInfo.isAppShell, + }; } } }) @@ -439,3 +449,15 @@ async function getAllRoutes( void renderWorker.destroy(); } } + +function getRouteWithoutBaseHref(route: string, baseHrefPathname: string): string { + return addTrailingSlash(route).startsWith(baseHrefPathname) + ? addLeadingSlash(route.slice(baseHrefPathname.length)) + : route; +} + +function getRouteOutPath(route: string, baseHrefPathname: string): string { + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname); + + return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); +} diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts index b8f01c440c4b..df1fdb4ea103 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -21,11 +21,15 @@ export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { hasSsrEntry: boolean; } -export interface RenderResultItem { - url: string; - content: string | null; - error?: string; -} +export type RenderResultItem = + | { + url: string; + content: string; + } + | { + url: string; + error: string; + }; export type RenderResult = RenderResultItem[]; @@ -74,12 +78,16 @@ async function renderPages(urls: string[]): Promise { for (const currentUrl of urls) { try { const content = await renderPage(currentUrl, angularServerApp); + + if (content === null) { + throw new Error('The content returned was empty.'); + } + results.push({ url: currentUrl, content }); } catch (err) { assertIsError(err); results.push({ url: currentUrl, - content: null, error: err.stack ?? err.message ?? err.code ?? `${err}`, }); } diff --git a/tests/e2e/tests/build/prerender/error-component-render.ts b/tests/e2e/tests/build/prerender/error-component-render.ts new file mode 100644 index 000000000000..07c048c71c39 --- /dev/null +++ b/tests/e2e/tests/build/prerender/error-component-render.ts @@ -0,0 +1,72 @@ +import { existsSync } from 'node:fs'; +import assert, { match } from 'node:assert'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileNotToExist, readFile, rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useSha } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + return; + } + + // Forcibly remove in case another test doesn't clean itself up. + await rimraf('node_modules/@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-home', + standalone: true, + template: '

home works!

', + }) + export class HomeRoute {} + + @Component({ + selector: 'app-second', + standalone: true, + template: '

second works!

', + }) + export class SecondRoute { + constructor() { + throw new Error('render failure'); + } + } + + export const routes: Routes = [ + { path: '', component: HomeRoute }, + { path: 'second', component: SecondRoute }, + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: 'second', renderMode: RenderMode.Prerender }, + { path: '**', renderMode: RenderMode.Prerender }, + ]; + `, + }); + + const { message } = await expectToFail(() => ng('build', '--output-mode=server')); + + match(message, /An error occurred while prerendering route '\/second'\./); + + await expectFileNotToExist('dist/test-project/browser/second/index.html'); + + // prerendered-routes.json should only contain successfully prerendered routes if emitted + const statsPath = 'dist/test-project/prerendered-routes.json'; + if (existsSync(statsPath)) { + const stats = JSON.parse(await readFile(statsPath)); + assert.strictEqual(stats.routes['/second'], undefined); + } +}