Skip to content

Commit 1356bd1

Browse files
committed
fixup! fix(@angular/build): merge component stylesheet metafiles in a stable order
1 parent 0042601 commit 1356bd1

1 file changed

Lines changed: 166 additions & 47 deletions

File tree

‎packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts‎

Lines changed: 166 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,40 @@
77
*/
88

99
import { buildApplication } from '../../index';
10+
import { OutputHashing } from '../../schema';
1011
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
1112

13+
/** Returns a copy of the value with object keys sorted at every level. Arrays keep their order. */
14+
function sortKeys(value: unknown): unknown {
15+
if (Array.isArray(value)) {
16+
return value.map(sortKeys);
17+
}
18+
if (value && typeof value === 'object') {
19+
const record = value as Record<string, unknown>;
20+
21+
return Object.fromEntries(
22+
Object.keys(record)
23+
.sort()
24+
.map((key) => [key, sortKeys(record[key])]),
25+
);
26+
}
27+
28+
return value;
29+
}
30+
31+
/** A standalone component in `src/app/<dir>` that uses the stylesheet `src/app/<dir>/shared.css`. */
32+
function sharedStylesheetComponent(dir: string): string {
33+
return `
34+
import { Component } from '@angular/core';
35+
@Component({
36+
selector: 'app-shared-${dir}',
37+
template: '<p>${dir}</p>',
38+
styleUrl: './shared.css',
39+
})
40+
export class Shared${dir.toUpperCase()}Component {}
41+
`;
42+
}
43+
1244
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
1345
describe('Option: "statsJson"', () => {
1446
it('generates only browser stats file containing valid metafile data when true', async () => {
@@ -52,60 +84,128 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
5284
harness.expectFile('dist/server-stats.json').toNotExist();
5385
});
5486

55-
it('attributes a shared component stylesheet output to every input on every build', async () => {
56-
// Two components in different directories with byte-identical stylesheets of the same
57-
// file name bundle to the same output file. The merged metafile must list both inputs.
58-
const component = (dir: string) => `
59-
import { Component } from '@angular/core';
60-
@Component({
61-
selector: 'app-shared-${dir}',
62-
template: '<p>${dir}</p>',
63-
styleUrl: './shared.css',
64-
})
65-
export class Shared${dir.toUpperCase()}Component {}
66-
`;
67-
await harness.writeFiles({
68-
'src/app/a/shared.component.ts': component('a'),
69-
'src/app/a/shared.css': 'p { color: red; }',
70-
'src/app/b/shared.component.ts': component('b'),
71-
'src/app/b/shared.css': 'p { color: red; }',
72-
});
73-
await harness.writeFile(
74-
'src/app/app.module.ts',
75-
`
76-
import { NgModule } from '@angular/core';
77-
import { BrowserModule } from '@angular/platform-browser';
78-
import { AppComponent } from './app.component';
79-
import { SharedAComponent } from './a/shared.component';
80-
import { SharedBComponent } from './b/shared.component';
81-
@NgModule({
82-
declarations: [AppComponent],
83-
imports: [BrowserModule, SharedAComponent, SharedBComponent],
84-
bootstrap: [AppComponent],
85-
})
86-
export class AppModule {}
87-
`,
88-
);
89-
await harness.writeFile(
90-
'src/app/app.component.html',
91-
'<app-shared-a></app-shared-a><app-shared-b></app-shared-b>',
92-
);
93-
87+
it('writes the stats file with sorted keys', async () => {
9488
harness.useTarget('build', {
9589
...BASE_OPTIONS,
9690
statsJson: true,
9791
});
9892

99-
for (let i = 0; i < 2; i++) {
100-
const { result } = await harness.executeOnce();
101-
expect(result?.success).toBeTrue();
93+
const { result } = await harness.executeOnce();
94+
expect(result?.success).toBeTrue();
95+
96+
const text = harness.readFile('dist/browser-stats.json');
97+
expect(text).toEqual(JSON.stringify(sortKeys(JSON.parse(text)), null, 2));
98+
});
99+
100+
describe('shared component stylesheet', () => {
101+
beforeEach(async () => {
102+
// Two components in different directories with byte-identical stylesheets of the same
103+
// file name bundle to the same output file.
104+
await harness.writeFiles({
105+
'src/app/a/shared.component.ts': sharedStylesheetComponent('a'),
106+
'src/app/a/shared.css': 'p { color: red; }',
107+
'src/app/b/shared.component.ts': sharedStylesheetComponent('b'),
108+
'src/app/b/shared.css': 'p { color: red; }',
109+
'src/app/app.component.html':
110+
'<app-shared-a></app-shared-a><app-shared-b></app-shared-b>',
111+
});
112+
await harness.writeFile(
113+
'src/app/app.module.ts',
114+
`
115+
import { NgModule } from '@angular/core';
116+
import { BrowserModule } from '@angular/platform-browser';
117+
import { AppComponent } from './app.component';
118+
import { SharedAComponent } from './a/shared.component';
119+
import { SharedBComponent } from './b/shared.component';
120+
@NgModule({
121+
declarations: [AppComponent],
122+
imports: [BrowserModule, SharedAComponent, SharedBComponent],
123+
bootstrap: [AppComponent],
124+
})
125+
export class AppModule {}
126+
`,
127+
);
128+
});
129+
130+
it('attributes a shared component stylesheet output to every input on every build', async () => {
131+
harness.useTarget('build', {
132+
...BASE_OPTIONS,
133+
statsJson: true,
134+
});
135+
136+
for (let i = 0; i < 2; i++) {
137+
const { result } = await harness.executeOnce();
138+
expect(result?.success).toBeTrue();
139+
140+
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
141+
expect(Object.keys(stats.outputs['shared.css'].inputs)).toEqual([
142+
'src/app/a/shared.css',
143+
'src/app/b/shared.css',
144+
]);
145+
}
146+
});
102147

103-
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
104-
expect(Object.keys(stats.outputs['shared.css'].inputs)).toEqual([
105-
'src/app/a/shared.css',
106-
'src/app/b/shared.css',
148+
it('re-attributes a shared component stylesheet output after one stylesheet changes', async () => {
149+
// Hashed output names keep the two stylesheets apart once their content differs, so the
150+
// outputs are found by their inputs. Minified styles carry no source path comment, which
151+
// gives the identical stylesheets the same hashed name.
152+
harness.useTarget('build', {
153+
...BASE_OPTIONS,
154+
statsJson: true,
155+
optimization: { styles: true },
156+
outputHashing: OutputHashing.All,
157+
watch: true,
158+
});
159+
160+
const inputsOfOutputFor = (stylesheet: string): string[][] => {
161+
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
162+
163+
return Object.values<{ inputs: Record<string, unknown> }>(stats.outputs)
164+
.map((output) => Object.keys(output.inputs))
165+
.filter((inputs) => inputs.includes(stylesheet));
166+
};
167+
168+
await harness.executeWithCases([
169+
async ({ result }) => {
170+
expect(result?.success).toBeTrue();
171+
expect(inputsOfOutputFor('src/app/b/shared.css')).toEqual([
172+
['src/app/a/shared.css', 'src/app/b/shared.css'],
173+
]);
174+
175+
await harness.writeFile('src/app/a/shared.css', 'p { color: blue; }');
176+
},
177+
async ({ result }) => {
178+
expect(result?.success).toBeTrue();
179+
expect(inputsOfOutputFor('src/app/b/shared.css')).toEqual([['src/app/b/shared.css']]);
180+
expect(inputsOfOutputFor('src/app/a/shared.css')).toEqual([['src/app/a/shared.css']]);
181+
182+
await harness.writeFile('src/app/a/shared.css', 'p { color: red; }');
183+
},
184+
async ({ result }) => {
185+
expect(result?.success).toBeTrue();
186+
expect(inputsOfOutputFor('src/app/b/shared.css')).toEqual([
187+
['src/app/a/shared.css', 'src/app/b/shared.css'],
188+
]);
189+
190+
// The restore re-bundled only `a`, so `b` now merges first. A second change to `a`
191+
// checks that the merge left the cached `b` result untouched.
192+
await harness.writeFile('src/app/a/shared.css', 'p { color: green; }');
193+
},
194+
async ({ result }) => {
195+
expect(result?.success).toBeTrue();
196+
expect(inputsOfOutputFor('src/app/b/shared.css')).toEqual([['src/app/b/shared.css']]);
197+
expect(inputsOfOutputFor('src/app/a/shared.css')).toEqual([['src/app/a/shared.css']]);
198+
199+
await harness.writeFile('src/app/a/shared.css', 'p { color: red; }');
200+
},
201+
({ result }) => {
202+
expect(result?.success).toBeTrue();
203+
expect(inputsOfOutputFor('src/app/b/shared.css')).toEqual([
204+
['src/app/a/shared.css', 'src/app/b/shared.css'],
205+
]);
206+
},
107207
]);
108-
}
208+
});
109209
});
110210

111211
describe('server build', () => {
@@ -119,6 +219,25 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
119219
});
120220
});
121221

222+
it('writes both stats files with sorted keys', async () => {
223+
harness.useTarget('build', {
224+
...BASE_OPTIONS,
225+
server: 'src/main.server.ts',
226+
ssr: true,
227+
statsJson: true,
228+
});
229+
230+
const { result } = await harness.executeOnce();
231+
expect(result?.success).toBeTrue();
232+
233+
for (const file of ['dist/browser-stats.json', 'dist/server-stats.json']) {
234+
const text = harness.readFile(file);
235+
expect(text)
236+
.withContext(file)
237+
.toEqual(JSON.stringify(sortKeys(JSON.parse(text)), null, 2));
238+
}
239+
});
240+
122241
it('generates separated browser and server stats files for an SSR build', async () => {
123242
harness.useTarget('build', {
124243
...BASE_OPTIONS,

0 commit comments

Comments
 (0)