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
41 changes: 39 additions & 2 deletions packages/angular/build/src/builders/application/execute-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { BuilderContext } from '@angular-devkit/architect';
import type { Metafile } from 'esbuild';
import { createAngularCompilation } from '../../tools/angular/compilation';
import {
AngularCompilationContext,
Expand Down Expand Up @@ -43,6 +44,42 @@ import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundl
/** The esbuild error text prefix used to detect top-level await errors. */
const TOP_LEVEL_AWAIT_ERROR_TEXT =
'Top-level await is not available in the configured target environment';

/** Returns a shallow copy of a record with its keys sorted. */
function sortObjectKeys<T extends Record<string, unknown>>(value: T): T {
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
sorted[key] = value[key];
}

return sorted as T;
}

/**
* Serializes a metafile with its dynamic path keys sorted so the stats file is identical across
* builds. Only `inputs`, `outputs`, and each output's `inputs` have dynamic keys; the remaining
* fixed-schema objects are left untouched so V8 keeps its native `JSON.stringify` fast path.
*/
function stableStringify(metafile: Metafile): string {
const outputs: Metafile['outputs'] = {};
for (const key of Object.keys(metafile.outputs).sort()) {
const output = metafile.outputs[key];
outputs[key] = {
...output,
inputs: sortObjectKeys(output.inputs),
};
}

return JSON.stringify(
{
inputs: sortObjectKeys(metafile.inputs),
outputs,
},
null,
2,
);
}
Comment thread
alan-agius4 marked this conversation as resolved.

// eslint-disable-next-line max-lines-per-function
export async function executeBuild(
options: NormalizedApplicationBuildOptions,
Expand Down Expand Up @@ -394,14 +431,14 @@ export async function executeBuild(
if (options.stats) {
executionResult.addOutputFile(
'browser-stats.json',
JSON.stringify(browserMetafile, null, 2),
stableStringify(browserMetafile),
BuildOutputFileType.Root,
);

if (serverEntryPoint) {
executionResult.addOutputFile(
'server-stats.json',
JSON.stringify(serverMetafile, null, 2),
stableStringify(serverMetafile),
BuildOutputFileType.Root,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,33 @@
*/

import { buildApplication } from '../../index';
import { OutputHashing } from '../../schema';
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';

/** Asserts that a record's keys are already in sorted order. */
function expectSortedKeys(record: Record<string, unknown>, context: string): void {
const keys = Object.keys(record);
expect(keys)
.withContext(context)
.toEqual([...keys].sort());
}

/** A standalone component in `src/app/<dir>` that uses the stylesheet `src/app/<dir>/shared.css`. */
function sharedStylesheetComponent(dir: string): string {
return `
import { Component } from '@angular/core';
@Component({
selector: 'app-shared-${dir}',
template: '<p>${dir}</p>',
styleUrl: './shared.css',
})
export class Shared${dir.toUpperCase()}Component {}
`;
}

describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
describe('Option: "statsJson"', () => {
it('generates only browser stats file containing valid metafile data when true', async () => {
it('generates only browser stats file with valid, sorted metafile data when true', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
Expand All @@ -27,6 +49,8 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
expect(browserStats.inputs).toBeDefined();
expect(browserStats.outputs).toBeDefined();
expect(Object.keys(browserStats.outputs).length).toBeGreaterThan(0);
expectSortedKeys(browserStats.inputs, 'inputs');
expectSortedKeys(browserStats.outputs, 'outputs');
});

it('does not generate stats files when false', async () => {
Expand All @@ -52,6 +76,131 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/server-stats.json').toNotExist();
});

describe('shared component stylesheet', () => {
beforeEach(async () => {
// Two components in different directories with byte-identical stylesheets of the same
// file name bundle to the same output file.
await harness.writeFiles({
'src/app/a/shared.component.ts': sharedStylesheetComponent('a'),
'src/app/a/shared.css': 'p { color: red; }',
'src/app/b/shared.component.ts': sharedStylesheetComponent('b'),
'src/app/b/shared.css': 'p { color: red; }',
'src/app/app.component.html':
'<app-shared-a></app-shared-a><app-shared-b></app-shared-b>',
'src/app/app.module.ts': `
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { SharedAComponent } from './a/shared.component';
import { SharedBComponent } from './b/shared.component';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, SharedAComponent, SharedBComponent],
bootstrap: [AppComponent],
})
export class AppModule {}
`,
});
});

it('attributes a shared component stylesheet output to every input', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
});

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();

// Both inputs are attributed to the single shared output, and in sorted order.
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
expect(Object.keys(stats.outputs['shared.css'].inputs)).toEqual([
'src/app/a/shared.css',
'src/app/b/shared.css',
]);
});

it('re-attributes a shared component stylesheet output after one stylesheet changes', async () => {
// Hashed output names keep the two stylesheets apart once their content differs, so the
// outputs are found by their inputs. Minified styles carry no source path comment, which
// gives the identical stylesheets the same hashed name.
harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
optimization: { styles: true },
outputHashing: OutputHashing.All,
watch: true,
});

await harness.executeWithCases([
async ({ result }) => {
expect(result?.success).toBeTrue();
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
expect(Object.values(stats.outputs)).toContain(
jasmine.objectContaining({
inputs: {
'src/app/a/shared.css': jasmine.anything(),
'src/app/b/shared.css': jasmine.anything(),
},
}),
);

await harness.writeFile('src/app/a/shared.css', 'p { color: blue; }');
},
async ({ result }) => {
expect(result?.success).toBeTrue();
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
expect(Object.values(stats.outputs)).toContain(
jasmine.objectContaining({
inputs: { 'src/app/a/shared.css': jasmine.anything() },
}),
);
expect(Object.values(stats.outputs)).toContain(
jasmine.objectContaining({
inputs: { 'src/app/b/shared.css': jasmine.anything() },
}),
);

await harness.writeFile('src/app/a/shared.css', 'p { color: red; }');
},
async ({ result }) => {
expect(result?.success).toBeTrue();
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
expect(Object.values(stats.outputs)).toContain(
jasmine.objectContaining({
inputs: {
'src/app/a/shared.css': jasmine.anything(),
'src/app/b/shared.css': jasmine.anything(),
},
}),
);

// The restore re-bundled only `a`, so `b` now merges first. A second change to `a`
// checks that the merge left the cached `b` result untouched.
await harness.writeFile('src/app/a/shared.css', 'p { color: green; }');
},
({ result }) => {
expect(result?.success).toBeTrue();
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
// The inner `inputs` objects are matched exactly, not with `objectContaining`: if the
// previous case had mutated the cached `b` result in place, `b` would read
// `{ a, b }` here and this exact match would fail. This is the guard for the
// clone-on-first-insert in the metafile merge, so it must stay an exact match.
expect(Object.values(stats.outputs)).toContain(
jasmine.objectContaining({
inputs: { 'src/app/a/shared.css': jasmine.anything() },
}),
);
expect(Object.values(stats.outputs)).toContain(
jasmine.objectContaining({
inputs: { 'src/app/b/shared.css': jasmine.anything() },
}),
);
},
]);
});
});
Comment thread
alan-agius4 marked this conversation as resolved.

describe('server build', () => {
beforeEach(async () => {
await harness.modifyFile('src/tsconfig.app.json', (content) => {
Expand All @@ -63,7 +212,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
});
});

it('generates separated browser and server stats files for an SSR build', async () => {
it('generates separated browser and server stats files with sorted keys for an SSR build', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
Expand All @@ -80,6 +229,11 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json'));
const serverStats = JSON.parse(harness.readFile('dist/server-stats.json'));

expectSortedKeys(browserStats.inputs, 'browser inputs');
expectSortedKeys(browserStats.outputs, 'browser outputs');
expectSortedKeys(serverStats.inputs, 'server inputs');
expectSortedKeys(serverStats.outputs, 'server outputs');

const browserPaths = new Set(Object.keys(browserStats.outputs));
const serverPaths = new Set(Object.keys(serverStats.outputs));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,21 @@ export function createCompilerPlugin(
// mitigating significant performance overhead for large apps.
// See: https://bugs.chromium.org/p/v8/issues/detail?id=11536
Object.assign(result.metafile.inputs, metafile.inputs);
Object.assign(result.metafile.outputs, metafile.outputs);
for (const [outputPath, output] of Object.entries(metafile.outputs)) {
// Two stylesheets can bundle to the same output file. Clone the inputs on first
// insert so colliding inputs can be unioned in place with Object.assign, keeping
// every input attributed without per-collision spread allocations and leaving the
// cached `additionalResults` metafile untouched.
const existing = result.metafile.outputs[outputPath];
if (existing) {
Object.assign(existing.inputs, output.inputs);
} else {
result.metafile.outputs[outputPath] = {
...output,
inputs: { ...output.inputs },
};
}
}
Comment thread
alan-agius4 marked this conversation as resolved.
}
}

Expand Down
Loading