refactor(@angular/ssr): remove RenderMode.AppShell in favor of new configuration option

This commit removes the `RenderMode.AppShell` option. Instead, a new configuration parameter, `{ appShellRoute: 'shell' }`, is introduced to the `provideServerRoutesConfig` method.

```ts
provideServerRoutesConfig(serverRoutes, { appShellRoute: 'shell' })
```
This commit is contained in:
Alan Agius 2024-11-08 18:27:37 +00:00 committed by Alan Agius
parent 553d3d7f6e
commit b2e2be052f
14 changed files with 174 additions and 146 deletions

View File

@ -24,26 +24,20 @@ export enum PrerenderFallback {
}
// @public
export function provideServerRoutesConfig(routes: ServerRoute[]): EnvironmentProviders;
export function provideServerRoutesConfig(routes: ServerRoute[], options?: ServerRoutesConfigOptions): EnvironmentProviders;
// @public
export enum RenderMode {
AppShell = 0,
Client = 2,
Prerender = 3,
Server = 1
Client = 1,
Prerender = 2,
Server = 0
}
// @public
export type RequestHandlerFunction = (request: Request) => Promise<Response | null> | null | Response;
// @public
export type ServerRoute = ServerRouteAppShell | ServerRouteClient | ServerRoutePrerender | ServerRoutePrerenderWithParams | ServerRouteServer;
// @public
export interface ServerRouteAppShell extends Omit<ServerRouteCommon, 'headers' | 'status'> {
renderMode: RenderMode.AppShell;
}
export type ServerRoute = ServerRouteClient | ServerRoutePrerender | ServerRoutePrerenderWithParams | ServerRouteServer;
// @public
export interface ServerRouteClient extends ServerRouteCommon {
@ -69,6 +63,11 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
getPrerenderParams: () => Promise<Record<string, string>[]>;
}
// @public
export interface ServerRoutesConfigOptions {
appShellRoute?: string;
}
// @public
export interface ServerRouteServer extends ServerRouteCommon {
renderMode: RenderMode.Server;

View File

@ -117,8 +117,6 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/browser/main.js').toExist();
const indexFileContent = harness.expectFile('dist/browser/index.html').content;
indexFileContent.toContain('app-shell works!');
// TODO(alanagius): enable once integration of routes in complete.
// indexFileContent.toContain('ng-server-context="app-shell"');
});
it('critical CSS is inlined', async () => {

View File

@ -23,6 +23,7 @@ export type WritableSerializableRouteTreeNode = Writeable<SerializableRouteTreeN
export interface RoutersExtractorWorkerResult {
serializedRouteTree: SerializableRouteTreeNode;
appShellRoute?: string;
errors: string[];
}
@ -33,8 +34,7 @@ export interface RoutersExtractorWorkerResult {
* It maps `RenderMode` enum values to their corresponding numeric identifiers.
*/
export const RouteRenderMode: Record<keyof typeof RenderMode, RenderMode> = {
AppShell: 0,
Server: 1,
Client: 2,
Prerender: 3,
Server: 0,
Client: 1,
Prerender: 2,
};

View File

@ -97,24 +97,26 @@ export async function prerenderPages(
}
// Get routes to prerender
const { errors: extractionErrors, serializedRouteTree: serializableRouteTreeNode } =
await getAllRoutes(
workspaceRoot,
baseHref,
outputFilesForWorker,
assetsReversed,
appShellOptions,
prerenderOptions,
sourcemap,
outputMode,
).catch((err) => {
return {
errors: [
`An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err}`,
],
serializedRouteTree: [],
};
});
const {
errors: extractionErrors,
serializedRouteTree: serializableRouteTreeNode,
appShellRoute,
} = await getAllRoutes(
workspaceRoot,
baseHref,
outputFilesForWorker,
assetsReversed,
appShellOptions,
prerenderOptions,
sourcemap,
outputMode,
).catch((err) => {
return {
errors: [`An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err}`],
serializedRouteTree: [],
appShellRoute: undefined,
};
});
errors.push(...extractionErrors);
@ -133,7 +135,6 @@ export async function prerenderPages(
switch (metadata.renderMode) {
case undefined: /* Legacy building mode */
case RouteRenderMode.Prerender:
case RouteRenderMode.AppShell:
serializableRouteTreeNodeForPrerender.push(metadata);
break;
case RouteRenderMode.Server:
@ -166,6 +167,7 @@ export async function prerenderPages(
assetsReversed,
appShellOptions,
outputMode,
appShellRoute ?? appShellOptions?.route,
);
errors.push(...renderingErrors);
@ -188,6 +190,7 @@ async function renderPages(
assetFilesForWorker: Record<string, string>,
appShellOptions: AppShellOptions | undefined,
outputMode: OutputMode | undefined,
appShellRoute: string | undefined,
): Promise<{
output: PrerenderOutput;
errors: string[];
@ -215,7 +218,7 @@ async function renderPages(
try {
const renderingPromises: Promise<void>[] = [];
const appShellRoute = appShellOptions && addLeadingSlash(appShellOptions.route);
const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute);
const baseHrefWithLeadingSlash = addLeadingSlash(baseHref);
for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) {
@ -232,16 +235,14 @@ async function renderPages(
continue;
}
const isAppShellRoute =
renderMode === RouteRenderMode.AppShell ||
// Legacy handling
(renderMode === undefined && appShellRoute === routeWithoutBaseHref);
const render: Promise<string | null> = renderWorker.run({ url: route, isAppShellRoute });
const render: Promise<string | null> = renderWorker.run({ url: route });
const renderResult: Promise<void> = render
.then((content) => {
if (content !== null) {
output[outPath] = { content, appShellRoute: isAppShellRoute };
output[outPath] = {
content,
appShellRoute: appShellRouteWithLeadingSlash === routeWithoutBaseHref,
};
}
})
.catch((err) => {
@ -274,14 +275,21 @@ async function getAllRoutes(
prerenderOptions: PrerenderOptions | undefined,
sourcemap: boolean,
outputMode: OutputMode | undefined,
): Promise<{ serializedRouteTree: SerializableRouteTreeNode; errors: string[] }> {
): Promise<{
serializedRouteTree: SerializableRouteTreeNode;
appShellRoute?: string;
errors: string[];
}> {
const { routesFile, discoverRoutes } = prerenderOptions ?? {};
const routes: WritableSerializableRouteTreeNode = [];
let appShellRoute: string | undefined;
if (appShellOptions) {
appShellRoute = urlJoin(baseHref, appShellOptions.route);
routes.push({
renderMode: RouteRenderMode.AppShell,
route: urlJoin(baseHref, appShellOptions.route),
renderMode: RouteRenderMode.Prerender,
route: appShellRoute,
});
}
@ -296,7 +304,7 @@ async function getAllRoutes(
}
if (!discoverRoutes) {
return { errors: [], serializedRouteTree: routes };
return { errors: [], appShellRoute, serializedRouteTree: routes };
}
const workerExecArgv = [IMPORT_EXEC_ARGV];
@ -319,12 +327,11 @@ async function getAllRoutes(
});
try {
const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run(
{},
);
const { serializedRouteTree, appShellRoute, errors }: RoutersExtractorWorkerResult =
await renderWorker.run({});
if (!routes.length) {
return { errors, serializedRouteTree };
return { errors, appShellRoute, serializedRouteTree };
}
// Merge the routing trees

View File

@ -33,7 +33,7 @@ async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } =
await loadEsmModuleFromMemory('./main.server.mjs');
const { routeTree, errors } = await extractRoutesAndCreateRouteTree(
const { routeTree, appShellRoute, errors } = await extractRoutesAndCreateRouteTree(
serverURL,
undefined /** manifest */,
true /** invokeGetPrerenderParams */,
@ -42,6 +42,7 @@ async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
return {
errors,
appShellRoute,
serializedRouteTree: routeTree.toObject(),
};
}

View File

@ -14,9 +14,9 @@ export { createRequestHandler, type RequestHandlerFunction } from './src/handler
export {
type PrerenderFallback,
type ServerRoute,
type ServerRoutesConfigOptions,
provideServerRoutesConfig,
RenderMode,
type ServerRouteAppShell,
type ServerRouteClient,
type ServerRoutePrerender,
type ServerRoutePrerenderWithParams,

View File

@ -35,13 +35,11 @@ const MAX_INLINE_CSS_CACHE_ENTRIES = 50;
*
* - `RenderMode.Prerender` maps to `'ssg'` (Static Site Generation).
* - `RenderMode.Server` maps to `'ssr'` (Server-Side Rendering).
* - `RenderMode.AppShell` maps to `'app-shell'` (pre-rendered application shell).
* - `RenderMode.Client` maps to an empty string `''` (Client-Side Rendering, no server context needed).
*/
const SERVER_CONTEXT_VALUE: Record<RenderMode, string> = {
[RenderMode.Prerender]: 'ssg',
[RenderMode.Server]: 'ssr',
[RenderMode.AppShell]: 'app-shell',
[RenderMode.Client]: '',
};
@ -237,11 +235,18 @@ export class AngularServerApp {
matchedRoute: RouteTreeNodeMetadata,
requestContext?: unknown,
): Promise<Response | null> {
const { renderMode, headers, status } = matchedRoute;
if (
!this.allowStaticRouteRender &&
(renderMode === RenderMode.Prerender || renderMode === RenderMode.AppShell)
) {
const { redirectTo, status } = matchedRoute;
if (redirectTo !== undefined) {
// Note: The status code is validated during route extraction.
// 302 Found is used by default for redirections
// See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Response.redirect(new URL(redirectTo, new URL(request.url)), (status as any) ?? 302);
}
const { renderMode, headers } = matchedRoute;
if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) {
return null;
}

View File

@ -22,7 +22,13 @@ import { Console } from '../console';
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
import { AngularBootstrap, isNgModule } from '../utils/ng';
import { joinUrlParts, stripLeadingSlash } from '../utils/url';
import { PrerenderFallback, RenderMode, SERVER_ROUTES_CONFIG, ServerRoute } from './route-config';
import {
PrerenderFallback,
RenderMode,
SERVER_ROUTES_CONFIG,
ServerRoute,
ServerRoutesConfig,
} from './route-config';
import { RouteTree, RouteTreeNodeMetadata } from './route-tree';
/**
@ -80,6 +86,11 @@ interface AngularRouterConfigResult {
* A list of errors encountered during the route extraction process.
*/
errors: string[];
/**
* The specified route for the app-shell, if configured.
*/
appShellRoute?: string;
}
/**
@ -317,7 +328,7 @@ function resolveRedirectTo(routePath: string, redirectTo: string): string {
/**
* Builds a server configuration route tree from the given server routes configuration.
*
* @param serverRoutesConfig - The array of server routes to be used for configuration.
* @param serverRoutesConfig - The server routes to be used for configuration.
* @returns An object containing:
* - `serverConfigRouteTree`: A populated `RouteTree` instance, which organizes the server routes
@ -325,14 +336,22 @@ function resolveRedirectTo(routePath: string, redirectTo: string): string {
* - `errors`: An array of strings that list any errors encountered during the route tree construction
* process, such as invalid paths.
*/
function buildServerConfigRouteTree(serverRoutesConfig: ServerRoute[]): {
function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfig): {
errors: string[];
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata>;
} {
const serverRoutes: ServerRoute[] = [...routes];
if (appShellRoute !== undefined) {
serverRoutes.unshift({
path: appShellRoute,
renderMode: RenderMode.Prerender,
});
}
const serverConfigRouteTree = new RouteTree<ServerConfigRouteTreeAdditionalMetadata>();
const errors: string[] = [];
for (const { path, ...metadata } of serverRoutesConfig) {
for (const { path, ...metadata } of serverRoutes) {
if (path[0] === '/') {
errors.push(`Invalid '${path}' route configuration: the path cannot start with a slash.`);
@ -442,18 +461,6 @@ export async function getRoutesFromAngularRouterConfig(
if ('error' in result) {
errors.push(result.error);
} else {
if (result.renderMode === RenderMode.AppShell) {
if (seenAppShellRoute !== undefined) {
errors.push(
`Error: Both '${seenAppShellRoute}' and '${stripLeadingSlash(result.route)}' routes have ` +
`their 'renderMode' set to 'AppShell'. AppShell renderMode should only be assigned to one route. ` +
`Please review your route configurations to ensure that only one route is set to 'RenderMode.AppShell'.`,
);
}
seenAppShellRoute = stripLeadingSlash(result.route);
}
routesResults.push(result);
}
}
@ -485,6 +492,7 @@ export async function getRoutesFromAngularRouterConfig(
baseHref,
routes: routesResults,
errors,
appShellRoute: serverRoutesConfig?.appShellRoute,
};
} finally {
platformRef.destroy();
@ -508,6 +516,7 @@ export async function getRoutesFromAngularRouterConfig(
*
* @returns A promise that resolves to an object containing:
* - `routeTree`: A populated `RouteTree` containing all extracted routes from the Angular application.
* - `appShellRoute`: The specified route for the app-shell, if configured.
* - `errors`: An array of strings representing any errors encountered during the route extraction process.
*/
export async function extractRoutesAndCreateRouteTree(
@ -515,11 +524,11 @@ export async function extractRoutesAndCreateRouteTree(
manifest: AngularAppManifest = getAngularAppManifest(),
invokeGetPrerenderParams = false,
includePrerenderFallbackRoutes = true,
): Promise<{ routeTree: RouteTree; errors: string[] }> {
): Promise<{ routeTree: RouteTree; appShellRoute?: string; errors: string[] }> {
const routeTree = new RouteTree();
const document = await new ServerAssets(manifest).getIndexServerHtml().text();
const bootstrap = await manifest.bootstrap();
const { baseHref, routes, errors } = await getRoutesFromAngularRouterConfig(
const { baseHref, appShellRoute, routes, errors } = await getRoutesFromAngularRouterConfig(
bootstrap,
document,
url,
@ -537,6 +546,7 @@ export async function extractRoutesAndCreateRouteTree(
}
return {
appShellRoute,
routeTree,
errors,
};

View File

@ -15,9 +15,6 @@ import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '
* @developerPreview
*/
export enum RenderMode {
/** AppShell rendering mode, typically used for pre-rendered shells of the application. */
AppShell,
/** Server-Side Rendering (SSR) mode, where content is rendered on the server for each request. */
Server,
@ -69,16 +66,6 @@ export interface ServerRouteCommon {
status?: number;
}
/**
* A server route that uses AppShell rendering mode.
* @see {@link RenderMode}
* @developerPreview
*/
export interface ServerRouteAppShell extends Omit<ServerRouteCommon, 'headers' | 'status'> {
/** Specifies that the route uses AppShell rendering mode. */
renderMode: RenderMode.AppShell;
}
/**
* A server route that uses Client-Side Rendering (CSR) mode.
* @see {@link RenderMode}
@ -165,27 +152,67 @@ export interface ServerRouteServer extends ServerRouteCommon {
* @developerPreview
*/
export type ServerRoute =
| ServerRouteAppShell
| ServerRouteClient
| ServerRoutePrerender
| ServerRoutePrerenderWithParams
| ServerRouteServer;
/**
* Configuration options for server routes.
*
* This interface defines the optional settings available for configuring server routes
* in the server-side environment, such as specifying a path to the app shell route.
*
* @see {@link provideServerRoutesConfig}
* @developerPreview
*/
export interface ServerRoutesConfigOptions {
/**
* Defines the route to be used as the app shell, which serves as the main entry
* point for the application. This route is often used to enable server-side rendering
* of the application shell for requests that do not match any specific server route.
*
* @see {@link https://angular.dev/ecosystem/service-workers/app-shell | App shell pattern on Angular.dev}
*/
appShellRoute?: string;
}
/**
* Configuration value for server routes configuration.
* @internal
*/
export interface ServerRoutesConfig extends ServerRoutesConfigOptions {
routes: ServerRoute[];
}
/**
* Token for providing the server routes configuration.
* @internal
*/
export const SERVER_ROUTES_CONFIG = new InjectionToken<ServerRoute[]>('SERVER_ROUTES_CONFIG');
export const SERVER_ROUTES_CONFIG = new InjectionToken<ServerRoutesConfig>('SERVER_ROUTES_CONFIG');
/**
* Configures the necessary providers for server routes configuration.
/**
* Sets up the necessary providers for configuring server routes.
* This function accepts an array of server routes and optional configuration
* options, returning an `EnvironmentProviders` object that encapsulates
* the server routes and configuration settings.
*
* @param routes - An array of server routes to be provided.
* @param options - (Optional) An object containing additional configuration options for server routes.
* @returns An `EnvironmentProviders` instance with the server routes configuration.
*
* @returns An `EnvironmentProviders` object that contains the server routes configuration.
*
* @see {@link ServerRoute}
* @see {@link ServerRoutesConfigOptions}
* @developerPreview
*/
export function provideServerRoutesConfig(routes: ServerRoute[]): EnvironmentProviders {
export function provideServerRoutesConfig(
routes: ServerRoute[],
options?: ServerRoutesConfigOptions,
): EnvironmentProviders {
if (typeof ngServerMode === 'undefined' || !ngServerMode) {
throw new Error(
`The 'provideServerRoutesConfig' function should not be invoked within the browser portion of the application.`,
@ -195,7 +222,7 @@ export function provideServerRoutesConfig(routes: ServerRoute[]): EnvironmentPro
return makeEnvironmentProviders([
{
provide: SERVER_ROUTES_CONFIG,
useValue: routes,
useValue: { routes, ...options },
},
]);
}

View File

@ -340,28 +340,6 @@ describe('extractRoutesAndCreateRouteTree', () => {
);
});
it(`should error when 'RenderMode.AppShell' is used on more than one route`, async () => {
setAngularAppTestingManifest(
[
{ path: 'home', component: DummyComponent },
{ path: 'shell', component: DummyComponent },
],
[{ path: '**', renderMode: RenderMode.AppShell }],
);
const { errors } = await extractRoutesAndCreateRouteTree(
url,
/** manifest */ undefined,
/** invokeGetPrerenderParams */ false,
/** includePrerenderFallbackRoutes */ false,
);
expect(errors).toHaveSize(1);
expect(errors[0]).toContain(
`Both 'home' and 'shell' routes have their 'renderMode' set to 'AppShell'.`,
);
});
it('should apply RenderMode matching the wildcard when no Angular routes are defined', async () => {
setAngularAppTestingManifest([], [{ path: '**', renderMode: RenderMode.Server }]);

View File

@ -126,8 +126,6 @@ describe('AppShell Builder', () => {
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toMatch('Welcome to app');
// TODO(alanagius): enable once integration of routes in complete.
// expect(content).toMatch('ng-server-context="app-shell"');
});
it('works with route', async () => {

View File

@ -265,7 +265,7 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule {
throw new SchematicsException(`Cannot find "${configFilePath}".`);
}
const recorder = host.beginUpdate(configFilePath);
let recorder = host.beginUpdate(configFilePath);
let configSourceFile = getSourceFile(host, configFilePath);
if (!isImported(configSourceFile, 'ROUTES', '@angular/router')) {
const routesChange = insertImport(
@ -306,6 +306,24 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule {
recorder.insertRight(providersLiteral.getStart(), `[\n${updatedProvidersString.join(',\n')}]`);
if (options.serverRouting) {
host.commitUpdate(recorder);
configSourceFile = getSourceFile(host, configFilePath);
const functionCall = findNodes(configSourceFile, ts.isCallExpression).find(
(n) =>
ts.isIdentifier(n.expression) && n.expression.getText() === 'provideServerRoutesConfig',
);
if (!functionCall) {
throw new SchematicsException(
`Cannot find the "provideServerRoutesConfig" function call in "${configFilePath}".`,
);
}
recorder = host.beginUpdate(configFilePath);
recorder.insertLeft(functionCall.end - 1, `, { appShellRoute: '${APP_SHELL_ROUTE}' }`);
}
// Add AppShellComponent import
const appShellImportChange = insertImport(
configSourceFile,
@ -376,9 +394,7 @@ export default function (options: AppShellOptions): Rule {
...(isStandalone
? [addStandaloneServerRoute(options)]
: [addRouterModule(browserEntryPoint), addServerRoutes(options)]),
options.serverRouting
? addServerRoutingConfig(options)
: addAppShellConfigToWorkspace(options),
options.serverRouting ? noop() : addAppShellConfigToWorkspace(options),
schematic('component', {
name: 'app-shell',
module: 'app.module.server.ts',

View File

@ -200,17 +200,12 @@ describe('App Shell Schematic', () => {
expect(content).toMatch(/app-shell\.component/);
});
it('should update the server routing configuration', async () => {
it(`should update the 'provideServerRoutesConfig' call to include 'appShellRoute`, async () => {
const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree);
const content = tree.readContent('/projects/bar/src/app/app.routes.server.ts');
expect(tags.oneLine`${content}`).toContain(tags.oneLine`{
path: 'shell',
renderMode: RenderMode.AppShell
},
{
path: '**',
renderMode: RenderMode.Prerender
}`);
const content = tree.readContent('/projects/bar/src/app/app.config.server.ts');
expect(tags.oneLine`${content}`).toContain(
tags.oneLine`provideServerRoutesConfig(serverRoutes, { appShellRoute: 'shell' })`,
);
});
it('should define a server route', async () => {

View File

@ -29,14 +29,9 @@ export default async function () {
import { CsrComponent } from './csr/csr.component';
import { SsrComponent } from './ssr/ssr.component';
import { SsgComponent } from './ssg/ssg.component';
import { AppShellComponent } from './app-shell/app-shell.component';
import { SsgWithParamsComponent } from './ssg-with-params/ssg-with-params.component';
export const routes: Routes = [
{
path: 'app-shell',
component: AppShellComponent
},
{
path: '',
component: HomeComponent,
@ -88,10 +83,6 @@ export default async function () {
renderMode: RenderMode.Client,
headers: { 'x-custom': 'csr' },
},
{
path: 'app-shell',
renderMode: RenderMode.AppShell,
},
{
path: '**',
renderMode: RenderMode.Prerender,
@ -102,12 +93,15 @@ export default async function () {
);
// Generate components for the above routes
const componentNames: string[] = ['home', 'ssg', 'ssg-with-params', 'csr', 'ssr', 'app-shell'];
const componentNames: string[] = ['home', 'ssg', 'ssg-with-params', 'csr', 'ssr'];
for (const componentName of componentNames) {
await silentNg('generate', 'component', componentName);
}
// Generate app-shell
await ng('g', 'app-shell');
await noSilentNg('build', '--output-mode=server');
const expects: Record<string, string> = {
@ -155,7 +149,7 @@ export default async function () {
},
'/csr': {
content: 'app-shell works',
serverContext: 'ng-server-context="app-shell"',
serverContext: 'ng-server-context="ssg"',
headers: {
'x-custom': 'csr',
},