Skip to content
Open
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 @@ -132,5 +132,119 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
{ outputLogsOnFailure: false },
);
});

it('rebuilds PostCSS stylesheet after error on rebuild from plugin dependency', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
watch: true,
styles: ['src/styles.css'],
});

await harness.writeFile(
'test-plugin.js',
`
const fs = require('fs');
const path = require('path');
module.exports = () => {
return {
postcssPlugin: 'test-plugin',
Once(root, { result }) {
const themePath = path.join(path.dirname(root.source.input.file), 'theme.json');
result.messages.push({
type: 'dependency',
file: themePath,
});
const data = fs.readFileSync(themePath, 'utf-8');
const json = JSON.parse(data);
root.append('body { color: ' + json.color + '; }');
},
};
};
module.exports.postcss = true;
`,
);
await harness.writeFile(
'.postcssrc.json',
JSON.stringify({
plugins: {
'./test-plugin.js': {},
},
}),
);
await harness.writeFile('src/styles.css', '/* base */');
await harness.writeFile('src/theme.json', '{"color": "aqua"}');

await harness.executeWithCases(
[
async ({ result }) => {
expect(result?.success).toBe(true);
harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua');
harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue');

await harness.writeFile('src/theme.json', 'invalid-json');
},
async ({ result }) => {
expect(result?.success).toBe(false);

await harness.writeFile('src/theme.json', '{"color": "blue"}');
},
({ result }) => {
expect(result?.success).toBe(true);
harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua');
harness.expectFile('dist/browser/styles.css').content.toContain('color: blue');
},
],
{ outputLogsOnFailure: false },
);
});

it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
watch: true,
styles: ['src/styles.css'],
});

await harness.writeFile(
'noop-plugin.js',
`
module.exports = () => ({ postcssPlugin: 'noop-plugin' });
module.exports.postcss = true;
`,
);
await harness.writeFile(
'.postcssrc.json',
JSON.stringify({
plugins: {
'./noop-plugin.js': {},
},
}),
);
await harness.writeFile('src/styles.css', "@import './a.css';");
await harness.writeFile('src/a.css', "a { ' }");

await harness.executeWithCases(
[
async ({ result }) => {
expect(result?.success).toBe(false);

await harness.writeFile('src/a.css', 'body { color: aqua; }');
},
async ({ result }) => {
expect(result?.success).toBe(true);
harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua');
harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue');

await harness.writeFile('src/a.css', 'body { color: blue; }');
},
({ result }) => {
expect(result?.success).toBe(true);
harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua');
harness.expectFile('dist/browser/styles.css').content.toContain('color: blue');
},
],
{ outputLogsOnFailure: false },
);
});
});
});
15 changes: 15 additions & 0 deletions packages/angular/build/src/tools/esbuild/load-result-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,26 @@ export function createCachedLoad(
export class MemoryLoadResultCache implements LoadResultCache {
#loadResults = new Map<string, OnLoadResult>();
#fileDependencies = new Map<string, Set<string>>();
#watchFilesPerKey = new Map<string, ReadonlyArray<string>>();

get(path: string): OnLoadResult | undefined {
return this.#loadResults.get(path);
}

async put(path: string, result: OnLoadResult): Promise<void> {
if (result.errors && result.errors.length > 0) {
const previousWatchFiles = this.#watchFilesPerKey.get(path);
if (previousWatchFiles) {
result.watchFiles = Array.from(
new Set([...(result.watchFiles ?? []), ...previousWatchFiles]),
);
}
} else if (result.watchFiles && result.watchFiles.length > 0) {
this.#watchFilesPerKey.set(path, [...result.watchFiles]);
} else {
this.#watchFilesPerKey.delete(path);
}

this.#loadResults.set(path, result);
if (result.watchFiles) {
for (const watchFile of result.watchFiles) {
Expand Down Expand Up @@ -96,5 +110,6 @@ export class MemoryLoadResultCache implements LoadResultCache {
clear(): void {
this.#loadResults.clear();
this.#fileDependencies.clear();
this.#watchFilesPerKey.clear();
}
}
85 changes: 85 additions & 0 deletions packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { MemoryLoadResultCache } from './load-result-cache';

describe('MemoryLoadResultCache', () => {
let cache: MemoryLoadResultCache;

beforeEach(() => {
cache = new MemoryLoadResultCache();
});

it('should store and retrieve results', async () => {
const result = {
contents: 'body { color: red; }',
loader: 'css' as const,
};

await cache.put('file:/test/styles.css', result);
const cached = cache.get('file:/test/styles.css');

expect(cached).toBe(result);
});

it('should track watch files in fileDependencies', async () => {
const result = {
contents: 'body { color: red; }',
loader: 'css' as const,
watchFiles: ['/test/styles.css', '/test/theme.json'],
};

await cache.put('file:/test/styles.css', result);

expect(cache.watchFiles).toContain('/test/styles.css');
expect(cache.watchFiles).toContain('/test/theme.json');
});

it('should invalidate cached results when a dependency changes', async () => {
const result = {
contents: 'body { color: red; }',
loader: 'css' as const,
watchFiles: ['/test/styles.css', '/test/theme.json'],
};

await cache.put('file:/test/styles.css', result);
expect(cache.get('file:/test/styles.css')).toBe(result);

const invalidated = cache.invalidate('/test/theme.json');
expect(invalidated).toBeTrue();
expect(cache.get('file:/test/styles.css')).toBeUndefined();
});

it('should preserve previous watch files when caching an error result', async () => {
const successResult = {
contents: 'body { color: red; }',
loader: 'css' as const,
watchFiles: ['/test/styles.css', '/test/theme.json'],
};

await cache.put('file:/test/styles.css', successResult);
cache.invalidate('/test/theme.json');

// Simulate an incremental rebuild error result that only has the entry file in watchFiles
const errorResult = {
errors: [{ text: 'Syntax error in theme.json' }],
watchFiles: ['/test/styles.css'],
};

await cache.put('file:/test/styles.css', errorResult);

// Both the entry file and the previous dependency should be tracked
expect(cache.watchFiles).toContain('/test/styles.css');
expect(cache.watchFiles).toContain('/test/theme.json');

// Invalidating the dependency should clear the cached error result
const invalidated = cache.invalidate('/test/theme.json');
expect(invalidated).toBeTrue();
expect(cache.get('file:/test/styles.css')).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ async function compileString(
},
},
],
watchFiles: error.file && error.file !== filename ? [filename, error.file] : [filename],
};
} else {
assertIsError(error);
Expand Down