Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)) {
Expand All @@ -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) {
Expand Down
66 changes: 44 additions & 22 deletions packages/angular/build/src/utils/server-rendering/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,6 +68,7 @@ export async function prerenderPages(
output: PrerenderOutput;
warnings: string[];
errors: string[];
prerenderedRoutes: PrerenderedRoutesRecord;
serializableRouteTreeNode: SerializableRouteTreeNode;
}> {
const rawOutputFiles: Record<string, string> = {};
Expand Down Expand Up @@ -167,6 +171,7 @@ export async function prerenderPages(
errors,
warnings,
output: {},
prerenderedRoutes: {},
serializableRouteTreeNode,
};
}
Expand Down Expand Up @@ -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 };
}
}
Comment thread
alan-agius4 marked this conversation as resolved.

return {
errors,
warnings,
output,
prerenderedRoutes,
serializableRouteTreeNode,
};
}
Expand All @@ -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 };
Expand Down Expand Up @@ -305,20 +315,20 @@ async function renderPages(
const renderBatchPromise: Promise<RenderResult> = 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,
};
}
}
})
Expand Down Expand Up @@ -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'));
}
20 changes: 14 additions & 6 deletions packages/angular/build/src/utils/server-rendering/render-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -74,12 +78,16 @@ async function renderPages(urls: string[]): Promise<RenderResult> {
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}`,
});
}
Expand Down
72 changes: 72 additions & 0 deletions tests/e2e/tests/build/prerender/error-component-render.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>home works!</p>',
})
export class HomeRoute {}

@Component({
selector: 'app-second',
standalone: true,
template: '<p>second works!</p>',
})
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);
}
}