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); + } +}