Skip to content

Commit 8d6a561

Browse files
committed
feat(@schematics/angular): update app-shell and ssr schematics to adopt new Server Rendering API
This commit revises the app-shell and ssr schematics to incorporate the new Server Rendering API, along with the integration of server-side routes. BREAKING CHANGE: The app-shell schematic is no longer compatible with Webpack-based builders.
1 parent 2a1107d commit 8d6a561

File tree

45 files changed

+507
-692
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+507
-692
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ 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_SERVER, NormalizedApplicationBuildOptions } from './options';
32+
import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options';
3333
import { OutputMode } from './schema';
3434

3535
/**
@@ -154,7 +154,15 @@ export async function executePostBundleSteps(
154154
// Update the index contents with the app shell under these conditions:
155155
// - Replace 'index.html' with the app shell only if it hasn't been prerendered yet.
156156
// - Always replace 'index.csr.html' with the app shell.
157-
const filePath = appShellRoute && !indexHasBeenPrerendered ? indexHtmlOptions.output : path;
157+
let filePath = path;
158+
if (appShellRoute && !indexHasBeenPrerendered) {
159+
if (outputMode !== OutputMode.Server && indexHtmlOptions.output === INDEX_HTML_CSR) {
160+
filePath = 'index.html';
161+
} else {
162+
filePath = indexHtmlOptions.output;
163+
}
164+
}
165+
158166
additionalHtmlOutputFiles.set(
159167
filePath,
160168
createOutputFile(filePath, content, BuildOutputFileType.Browser),

packages/angular/ssr/schematics/ng-add/index_spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe('@angular/ssr ng-add schematic', () => {
5252
});
5353

5454
it('works', async () => {
55-
const filePath = '/projects/test-app/server.ts';
55+
const filePath = '/projects/test-app/src/server.ts';
5656

5757
expect(appTree.exists(filePath)).toBeFalse();
5858
const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree);

packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export async function extractMessages(
5151
buildOptions.budgets = undefined;
5252
buildOptions.index = false;
5353
buildOptions.serviceWorker = false;
54+
buildOptions.server = undefined;
5455
buildOptions.ssr = false;
5556
buildOptions.appShell = false;
5657
buildOptions.prerender = false;

packages/schematics/angular/app-shell/index.ts

Lines changed: 62 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ import {
2929
import { applyToUpdateRecorder } from '../utility/change';
3030
import { getAppModulePath, isStandaloneApp } from '../utility/ng-ast-utils';
3131
import { findBootstrapApplicationCall, getMainFilePath } from '../utility/standalone/util';
32-
import { getWorkspace, updateWorkspace } from '../utility/workspace';
33-
import { Builders } from '../utility/workspace-models';
32+
import { getWorkspace } from '../utility/workspace';
3433
import { Schema as AppShellOptions } from './schema';
3534

3635
const APP_SHELL_ROUTE = 'shell';
@@ -140,77 +139,6 @@ function validateProject(mainPath: string): Rule {
140139
};
141140
}
142141

143-
function addAppShellConfigToWorkspace(options: AppShellOptions): Rule {
144-
return (host, context) => {
145-
return updateWorkspace((workspace) => {
146-
const project = workspace.projects.get(options.project);
147-
if (!project) {
148-
return;
149-
}
150-
151-
const buildTarget = project.targets.get('build');
152-
if (buildTarget?.builder === Builders.Application) {
153-
// Application builder configuration.
154-
const prodConfig = buildTarget.configurations?.production;
155-
if (!prodConfig) {
156-
throw new SchematicsException(
157-
`A "production" configuration is not defined for the "build" builder.`,
158-
);
159-
}
160-
161-
prodConfig.appShell = true;
162-
163-
return;
164-
}
165-
166-
// Webpack based builders configuration.
167-
// Validation of targets is handled already in the main function.
168-
// Duplicate keys means that we have configurations in both server and build builders.
169-
const serverConfigKeys = project.targets.get('server')?.configurations ?? {};
170-
const buildConfigKeys = project.targets.get('build')?.configurations ?? {};
171-
172-
const configurationNames = Object.keys({
173-
...serverConfigKeys,
174-
...buildConfigKeys,
175-
});
176-
177-
const configurations: Record<string, {}> = {};
178-
for (const key of configurationNames) {
179-
if (!serverConfigKeys[key]) {
180-
context.logger.warn(
181-
`Skipped adding "${key}" configuration to "app-shell" target as it's missing from "server" target.`,
182-
);
183-
184-
continue;
185-
}
186-
187-
if (!buildConfigKeys[key]) {
188-
context.logger.warn(
189-
`Skipped adding "${key}" configuration to "app-shell" target as it's missing from "build" target.`,
190-
);
191-
192-
continue;
193-
}
194-
195-
configurations[key] = {
196-
browserTarget: `${options.project}:build:${key}`,
197-
serverTarget: `${options.project}:server:${key}`,
198-
};
199-
}
200-
201-
project.targets.add({
202-
name: 'app-shell',
203-
builder: Builders.AppShell,
204-
defaultConfiguration: configurations['production'] ? 'production' : undefined,
205-
options: {
206-
route: APP_SHELL_ROUTE,
207-
},
208-
configurations,
209-
});
210-
});
211-
};
212-
}
213-
214142
function addRouterModule(mainPath: string): Rule {
215143
return (host: Tree) => {
216144
const modulePath = getAppModulePath(host, mainPath);
@@ -313,6 +241,7 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule {
313241
throw new SchematicsException(`Cannot find "${configFilePath}".`);
314242
}
315243

244+
const recorder = host.beginUpdate(configFilePath);
316245
let configSourceFile = getSourceFile(host, configFilePath);
317246
if (!isImported(configSourceFile, 'ROUTES', '@angular/router')) {
318247
const routesChange = insertImport(
@@ -322,10 +251,8 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule {
322251
'@angular/router',
323252
);
324253

325-
const recorder = host.beginUpdate(configFilePath);
326254
if (routesChange) {
327255
applyToUpdateRecorder(recorder, [routesChange]);
328-
host.commitUpdate(recorder);
329256
}
330257
}
331258

@@ -340,45 +267,20 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule {
340267
}
341268

342269
// Add route to providers literal.
343-
const newProvidersLiteral = ts.factory.updateArrayLiteralExpression(providersLiteral, [
344-
...providersLiteral.elements,
345-
ts.factory.createObjectLiteralExpression(
346-
[
347-
ts.factory.createPropertyAssignment('provide', ts.factory.createIdentifier('ROUTES')),
348-
ts.factory.createPropertyAssignment('multi', ts.factory.createIdentifier('true')),
349-
ts.factory.createPropertyAssignment(
350-
'useValue',
351-
ts.factory.createArrayLiteralExpression(
352-
[
353-
ts.factory.createObjectLiteralExpression(
354-
[
355-
ts.factory.createPropertyAssignment(
356-
'path',
357-
ts.factory.createIdentifier(`'${APP_SHELL_ROUTE}'`),
358-
),
359-
ts.factory.createPropertyAssignment(
360-
'component',
361-
ts.factory.createIdentifier('AppShellComponent'),
362-
),
363-
],
364-
true,
365-
),
366-
],
367-
true,
368-
),
369-
),
370-
],
371-
true,
372-
),
373-
]);
374-
375-
const recorder = host.beginUpdate(configFilePath);
376270
recorder.remove(providersLiteral.getStart(), providersLiteral.getWidth());
377-
const printer = ts.createPrinter();
378-
recorder.insertRight(
379-
providersLiteral.getStart(),
380-
printer.printNode(ts.EmitHint.Unspecified, newProvidersLiteral, configSourceFile),
381-
);
271+
const updatedProvidersString = [
272+
...providersLiteral.elements.map((element) => ' ' + element.getText()),
273+
` {
274+
provide: ROUTES,
275+
multi: true,
276+
useValue: [{
277+
path: '${APP_SHELL_ROUTE}',
278+
component: AppShellComponent
279+
}]
280+
}\n `,
281+
];
282+
283+
recorder.insertRight(providersLiteral.getStart(), `[\n${updatedProvidersString.join(',\n')}]`);
382284

383285
// Add AppShellComponent import
384286
const appShellImportChange = insertImport(
@@ -393,6 +295,52 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule {
393295
};
394296
}
395297

298+
function addServerRoutingConfig(options: AppShellOptions): Rule {
299+
return async (host: Tree) => {
300+
const workspace = await getWorkspace(host);
301+
const project = workspace.projects.get(options.project);
302+
if (!project) {
303+
throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`);
304+
}
305+
306+
const configFilePath = join(project.sourceRoot ?? 'src', 'app/app.routes.server.ts');
307+
if (!host.exists(configFilePath)) {
308+
throw new SchematicsException(`Cannot find "${configFilePath}".`);
309+
}
310+
311+
const sourceFile = getSourceFile(host, configFilePath);
312+
const nodes = getSourceNodes(sourceFile);
313+
314+
// Find the serverRoutes variable declaration
315+
const serverRoutesNode = nodes.find(
316+
(node) =>
317+
ts.isVariableDeclaration(node) &&
318+
node.initializer &&
319+
ts.isArrayLiteralExpression(node.initializer) &&
320+
node.type &&
321+
ts.isArrayTypeNode(node.type) &&
322+
node.type.getText().includes('ServerRoute'),
323+
) as ts.VariableDeclaration | undefined;
324+
325+
if (!serverRoutesNode) {
326+
throw new SchematicsException(
327+
`Cannot find the "ServerRoute" configuration in "${configFilePath}".`,
328+
);
329+
}
330+
const recorder = host.beginUpdate(configFilePath);
331+
const arrayLiteral = serverRoutesNode.initializer as ts.ArrayLiteralExpression;
332+
const firstElementPosition =
333+
arrayLiteral.elements[0]?.getStart() ?? arrayLiteral.getStart() + 1;
334+
const newRouteString = `{
335+
path: '${APP_SHELL_ROUTE}',
336+
renderMode: RenderMode.AppShell
337+
},\n`;
338+
recorder.insertLeft(firstElementPosition, newRouteString);
339+
340+
host.commitUpdate(recorder);
341+
};
342+
}
343+
396344
export default function (options: AppShellOptions): Rule {
397345
return async (tree) => {
398346
const browserEntryPoint = await getMainFilePath(tree, options.project);
@@ -401,9 +349,9 @@ export default function (options: AppShellOptions): Rule {
401349
return chain([
402350
validateProject(browserEntryPoint),
403351
schematic('server', options),
404-
addAppShellConfigToWorkspace(options),
405352
isStandalone ? noop() : addRouterModule(browserEntryPoint),
406353
isStandalone ? addStandaloneServerRoute(options) : addServerRoutes(options),
354+
addServerRoutingConfig(options),
407355
schematic('component', {
408356
name: 'app-shell',
409357
module: 'app.module.server.ts',

packages/schematics/angular/app-shell/index_spec.ts

Lines changed: 19 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import { tags } from '@angular-devkit/core';
1010
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
1111
import { Schema as ApplicationOptions } from '../application/schema';
12-
import { Builders } from '../utility/workspace-models';
1312
import { Schema as WorkspaceOptions } from '../workspace/schema';
1413
import { Schema as AppShellOptions } from './schema';
1514

@@ -51,15 +50,6 @@ describe('App Shell Schematic', () => {
5150
);
5251
});
5352

54-
it('should add app shell configuration', async () => {
55-
const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree);
56-
const filePath = '/angular.json';
57-
const content = tree.readContent(filePath);
58-
const workspace = JSON.parse(content);
59-
const target = workspace.projects.bar.architect['build'];
60-
expect(target.configurations.production.appShell).toBeTrue();
61-
});
62-
6353
it('should ensure the client app has a router-outlet', async () => {
6454
appTree = await schematicRunner.runSchematic('workspace', workspaceOptions);
6555
appTree = await schematicRunner.runSchematic(
@@ -168,7 +158,7 @@ describe('App Shell Schematic', () => {
168158
expect(content).toMatch(
169159
/const routes: Routes = \[ { path: 'shell', component: AppShellComponent }\];/,
170160
);
171-
expect(content).toMatch(/ServerModule,\r?\n\s*RouterModule\.forRoot\(routes\),/);
161+
expect(content).toContain(`ServerModule, RouterModule.forRoot(routes)]`);
172162
});
173163

174164
it('should create the shell component', async () => {
@@ -205,22 +195,34 @@ describe('App Shell Schematic', () => {
205195
const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree);
206196
expect(tree.exists('/projects/bar/src/app/app-shell/app-shell.component.ts')).toBe(true);
207197
const content = tree.readContent('/projects/bar/src/app/app.config.server.ts');
198+
208199
expect(content).toMatch(/app-shell\.component/);
209200
});
210201

202+
it('should update the server routing configuration', async () => {
203+
const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree);
204+
const content = tree.readContent('/projects/bar/src/app/app.routes.server.ts');
205+
expect(tags.oneLine`${content}`).toContain(tags.oneLine`{
206+
path: 'shell',
207+
renderMode: RenderMode.AppShell
208+
},
209+
{
210+
path: '**',
211+
renderMode: RenderMode.Prerender
212+
}`);
213+
});
214+
211215
it('should define a server route', async () => {
212216
const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree);
213217
const filePath = '/projects/bar/src/app/app.config.server.ts';
214218
const content = tree.readContent(filePath);
215219
expect(tags.oneLine`${content}`).toContain(tags.oneLine`{
216220
provide: ROUTES,
217221
multi: true,
218-
useValue: [
219-
{
220-
path: 'shell',
221-
component: AppShellComponent
222-
}
223-
]
222+
useValue: [{
223+
path: 'shell',
224+
component: AppShellComponent
225+
}]
224226
}`);
225227
});
226228

@@ -240,44 +242,4 @@ describe('App Shell Schematic', () => {
240242
);
241243
});
242244
});
243-
244-
describe('Legacy browser builder', () => {
245-
function convertBuilderToLegacyBrowser(): void {
246-
const config = JSON.parse(appTree.readContent('/angular.json'));
247-
const build = config.projects.bar.architect.build;
248-
249-
build.builder = Builders.Browser;
250-
build.options = {
251-
...build.options,
252-
main: build.options.browser,
253-
browser: undefined,
254-
};
255-
256-
build.configurations.development = {
257-
...build.configurations.development,
258-
vendorChunk: true,
259-
namedChunks: true,
260-
buildOptimizer: false,
261-
};
262-
263-
appTree.overwrite('/angular.json', JSON.stringify(config, undefined, 2));
264-
}
265-
266-
beforeEach(async () => {
267-
appTree = await schematicRunner.runSchematic('application', appOptions, appTree);
268-
convertBuilderToLegacyBrowser();
269-
});
270-
271-
it('should add app shell configuration', async () => {
272-
const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree);
273-
const filePath = '/angular.json';
274-
const content = tree.readContent(filePath);
275-
const workspace = JSON.parse(content);
276-
const target = workspace.projects.bar.architect['app-shell'];
277-
expect(target.configurations.development.browserTarget).toEqual('bar:build:development');
278-
expect(target.configurations.development.serverTarget).toEqual('bar:server:development');
279-
expect(target.configurations.production.browserTarget).toEqual('bar:build:production');
280-
expect(target.configurations.production.serverTarget).toEqual('bar:server:production');
281-
});
282-
});
283245
});

0 commit comments

Comments
 (0)