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
23 changes: 16 additions & 7 deletions packages/angular/build/src/builders/library/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ export async function* executeLibraryBuilder(
poll,
preserveSymlinks,
signal,
watchFiles: watchedCompilationFiles,
watchFiles: [
...watchedCompilationFiles,
...normalizedOptions.assets.map((asset) => asset.input),
],
});

context.addTeardown?.(() => void watcher?.close());
Expand Down Expand Up @@ -224,9 +227,9 @@ async function executeBuild(
if (watcher) {
watcher.add(Array.from(watchedCompilationFiles));

const { assetsToEmit } = actionContext;
if (assetsToEmit?.length) {
watcher.add(assetsToEmit.map((asset) => asset.source));
const { collectedAssets } = actionContext;
if (collectedAssets?.filesToEmit.length) {
watcher.add(collectedAssets.filesToEmit.map((asset) => asset.source));
}
}
}
Expand Down Expand Up @@ -337,13 +340,19 @@ async function* runWatchLoop(
buildState.hasEntryPointsChanges ||
hasModifiedWatchedFile(changedFiles, watchedCompilationFiles, packageJsonPath);

const assetsToEmit = await collectAssetsToEmit(
const collectedAssets = await collectAssetsToEmit(
assets,
workspaceRoot,
buildState.hasEmittedAssets ? changedFiles : undefined,
buildState.emittedAssets,
);

if (!hasSourceChanges && !hasPackageJsonChanges && assetsToEmit.length === 0) {
if (
!hasSourceChanges &&
!hasPackageJsonChanges &&
collectedAssets.filesToEmit.length === 0 &&
collectedAssets.filesToDelete.length === 0
) {
continue;
}

Expand All @@ -357,7 +366,7 @@ async function* runWatchLoop(
context,
buildState,
modifiedFiles: changedFiles,
assetsToEmit,
collectedAssets,
},
withProgress,
watcher,
Expand Down
87 changes: 69 additions & 18 deletions packages/angular/build/src/builders/library/pipeline/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,60 @@ import type { NormalizedLibraryOptions } from '../types';
import { type DiskOutputFile, createDiskOutputFile } from './utils';

/**
* Resolves and collects configured library assets to be emitted to disk.
* Result of resolving and collecting library assets for a build iteration.
*/
export interface CollectedAssets {
/** Asset files to emit to disk, with POSIX source and destination paths. */
filesToEmit: DiskOutputFile[];

/** Previously emitted asset files whose source files were deleted and should be removed from output. */
filesToDelete: DiskOutputFile[];
}

interface AssetMatcher {
asset: NormalizedLibraryOptions['assets'][number];
prefix: string;
isMatch: picomatch.Matcher;
}

const assetMatcherCache = new WeakMap<NormalizedLibraryOptions['assets'], AssetMatcher[]>();

/**
* Resolves and collects configured library assets to be emitted to or deleted from disk.
*
* @param assets The normalized asset patterns with POSIX paths.
* @param workspaceRoot The normalized POSIX workspace root directory path.
* @param assets The normalized asset patterns with POSIX input and output paths.
* @param workspaceRoot The workspace root POSIX directory path.
* @param modifiedFiles Optional set of modified POSIX file paths for incremental copying in watch mode.
* @returns An array of disk file emission descriptors.
* @param emittedAssets Optional map of previously emitted POSIX source file paths to relative POSIX output paths.
* @returns The collected asset files to emit and delete.
*/
export async function collectAssetsToEmit(
assets: NormalizedLibraryOptions['assets'],
workspaceRoot: string,
modifiedFiles?: ReadonlySet<string>,
): Promise<DiskOutputFile[]> {
emittedAssets?: ReadonlyMap<string, string>,
): Promise<CollectedAssets> {
if (assets.length === 0 || modifiedFiles?.size === 0) {
return [];
return { filesToEmit: [], filesToDelete: [] };
}

if (modifiedFiles) {
const matchers = createAssetMatchers(assets);
const matchers = getAssetMatchers(assets);
const filesToEmit: DiskOutputFile[] = [];
const filesToDelete: DiskOutputFile[] = [];

for (const file of modifiedFiles) {
let isFile: boolean | undefined;

const previousOutput = emittedAssets?.get(file);
if (previousOutput !== undefined) {
isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
if (!isFile) {
filesToDelete.push(createDiskOutputFile(file, previousOutput));
continue;
}
}

for (const { asset, prefix, isMatch } of matchers) {
if (!file.startsWith(prefix)) {
continue;
Expand All @@ -45,28 +78,46 @@ export async function collectAssetsToEmit(
continue;
}

if (statSync(file, { throwIfNoEntry: false })?.isFile()) {
isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
if (isFile) {
filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative)));
}
}
}

return filesToEmit;
return { filesToEmit, filesToDelete };
}

const resolvedAssets = await resolveAssets(assets, workspaceRoot);

return resolvedAssets.map(({ source, destination }) => createDiskOutputFile(source, destination));
return {
filesToEmit: resolvedAssets.map(({ source, destination }) =>
createDiskOutputFile(source, destination),
),
filesToDelete: [],
};
}

function createAssetMatchers(assets: NormalizedLibraryOptions['assets']) {
return assets.map((asset) => {
const isMatch = picomatch(asset.glob, {
dot: true,
ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])],
/**
* Returns cached or newly compiled picomatch matchers and POSIX input prefixes for each asset configuration.
*
* @param assets The normalized asset patterns with POSIX input and output paths.
* @returns An array of asset matcher descriptors.
*/
function getAssetMatchers(assets: NormalizedLibraryOptions['assets']): AssetMatcher[] {
let matchers = assetMatcherCache.get(assets);
if (!matchers) {
matchers = assets.map((asset) => {
const isMatch = picomatch(asset.glob, {
dot: true,
ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])],
});
const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`;

return { asset, prefix, isMatch };
});
const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`;
assetMatcherCache.set(assets, matchers);
}

return { asset, prefix, isMatch };
});
return matchers;
}
146 changes: 128 additions & 18 deletions packages/angular/build/src/builders/library/pipeline/build-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
import type { BuilderContext } from '@angular-devkit/architect';
import path from 'node:path';
import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets';
import { emitFilesToDisk } from '../../../utils/disk-files';
import { deleteFilesFromDisk, emitFilesToDisk } from '../../../utils/disk-files';
import { assertIsError } from '../../../utils/error';
import type { NormalizedLibraryOptions, SingleBuildState } from '../types';
import { collectAssetsToEmit } from './assets';
import type { BundleResult, NormalizedLibraryOptions, SingleBuildState } from '../types';
import { type CollectedAssets, collectAssetsToEmit } from './assets';
import { bundleEntryPoints } from './bundler';
import { type LibraryCompilationDiagnostics, compileLibrary } from './compilation';
import { collectEntryPointsToBundle } from './entry-points';
import { generatePackageManifests } from './package-manifests';
import type { DiskOutputFile, OutputFile } from './utils';
import type { OutputFile } from './utils';

/**
* Result of executing a single library build iteration.
Expand All @@ -41,6 +41,9 @@ export function createSingleBuildState(): SingleBuildState {
previousBundleResults: new Map(),
pendingChangedEsmFiles: new Set(),
pendingChangedDtsFiles: new Set(),
emittedBundleFiles: new Set(),
emittedManifestFiles: new Set(),
emittedAssets: new Map(),
};
}

Expand Down Expand Up @@ -76,12 +79,12 @@ export interface BuildActionContext {
modifiedFiles?: Set<string>;

/**
* Asset files to emit to disk for the current build iteration.
* Collected asset changes for the current build iteration.
* Pre-collected in the watch loop to avoid redundant asset matching, or resolved and
* populated by `buildAction` when omitted (such as during the initial build) so the
* caller can register their source paths with the file watcher.
* caller can register their POSIX source paths with the file watcher.
*/
assetsToEmit?: DiskOutputFile[];
collectedAssets?: CollectedAssets;
}

/**
Expand Down Expand Up @@ -133,8 +136,12 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
}

const filesToEmit: OutputFile[] = [];
const filesToDelete = new Set<string>();
let nextBundleFiles: Set<string> | undefined;
let nextManifestFiles: Set<string> | undefined;

if (shouldCompileEntryPoints) {
const hadEntryPointsChanges = buildState.hasEntryPointsChanges;
buildState.hasCompilationError = true;
buildState.hasEntryPointsChanges = false;

Expand Down Expand Up @@ -212,6 +219,18 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
for (const [name, bundleResult] of bundleOutput.bundleResults) {
buildState.previousBundleResults.set(name, bundleResult);
}

if (
bundleOutput.bundleResults.size > 0 ||
hadEntryPointsChanges ||
buildState.emittedBundleFiles.size === 0
) {
nextBundleFiles = collectBundleOutputFiles(
buildState.previousBundleResults.values(),
buildState.emittedBundleFiles,
filesToDelete,
);
}
}

let hasTslibImport = false;
Expand All @@ -223,27 +242,45 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
}

if (!buildState.hasEmittedManifests || buildState.hasEmittedTslib !== hasTslibImport) {
filesToEmit.push(
...(await generatePackageManifests(
{
...options,
compilationMode: buildState.compilationMode ?? options.compilationMode ?? 'partial',
},
isWatchMode,
hasTslibImport,
)),
const manifestFiles = await generatePackageManifests(
{
...options,
compilationMode: buildState.compilationMode ?? options.compilationMode ?? 'partial',
},
isWatchMode,
hasTslibImport,
);

filesToEmit.push(...manifestFiles);

nextManifestFiles = new Set(manifestFiles.map((file) => file.path));
for (const prevFile of buildState.emittedManifestFiles) {
if (!nextManifestFiles.has(prevFile)) {
filesToDelete.add(prevFile);
}
}
}

const resolvedAssetsToEmit = (actionContext.assetsToEmit ??= await collectAssetsToEmit(
const collectedAssets = (actionContext.collectedAssets ??= await collectAssetsToEmit(
options.assets,
options.workspaceRoot,
buildState.hasEmittedAssets ? modifiedFiles : undefined,
buildState.emittedAssets,
));
filesToEmit.push(...resolvedAssetsToEmit);
filesToEmit.push(...collectedAssets.filesToEmit);

await emitFilesToDisk(filesToEmit, (file) => path.posix.join(options.outputPath, file.path));

updateEmittedAssetState(buildState, collectedAssets, filesToDelete);

if (filesToDelete.size) {
await deleteFilesFromDisk(
Array.from(filesToDelete, (file) => path.join(options.outputPath, file)),
Comment thread
alan-agius4 marked this conversation as resolved.
);
}

buildState.emittedBundleFiles = nextBundleFiles ?? buildState.emittedBundleFiles;
buildState.emittedManifestFiles = nextManifestFiles ?? buildState.emittedManifestFiles;
buildState.hasEmittedManifests = true;
buildState.hasEmittedTslib = hasTslibImport;
buildState.hasEmittedAssets = true;
Expand All @@ -255,6 +292,79 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
return { errors, warnings, logs };
}

/**
* Collects the active POSIX bundle output file paths across all entry points and queues any
* previously emitted bundle files that are no longer present for deletion.
*
* @param bundleResults Active bundle results across all entry points.
* @param previousBundleFiles Relative POSIX output paths emitted in the previous build iteration.
* @param filesToDelete Set of relative POSIX output paths queued for deletion.
* @returns The new set of active relative POSIX bundle output file paths.
*/
function collectBundleOutputFiles(
bundleResults: Iterable<BundleResult>,
previousBundleFiles: ReadonlySet<string>,
filesToDelete: Set<string>,
): Set<string> {
const nextBundleFiles = new Set<string>();
for (const result of bundleResults) {
if (result.esmOutputFiles) {
for (const file of result.esmOutputFiles) {
nextBundleFiles.add(file);
}
}
if (result.dtsOutputFiles) {
for (const file of result.dtsOutputFiles) {
nextBundleFiles.add(file);
}
}
}

for (const prevFile of previousBundleFiles) {
if (!nextBundleFiles.has(prevFile)) {
filesToDelete.add(prevFile);
}
}

return nextBundleFiles;
}

/**
* Updates `buildState.emittedAssets` with newly emitted and deleted assets, queuing any
* deleted asset POSIX output paths that are not produced by another active asset for removal.
*
* @param buildState The state preserved across incremental builds.
* @param collectedAssets The collected asset files to emit and delete.
* @param filesToDelete Set of relative POSIX output paths queued for deletion.
*/
function updateEmittedAssetState(
buildState: SingleBuildState,
collectedAssets: CollectedAssets,
filesToDelete: Set<string>,
): void {
const emittedAssets = buildState.emittedAssets;
const toDelete = collectedAssets.filesToDelete;
const toEmit = collectedAssets.filesToEmit;

for (const deletedAsset of toDelete) {
emittedAssets.delete(deletedAsset.source);
}

for (const emittedAsset of toEmit) {
emittedAssets.set(emittedAsset.source, emittedAsset.path);
}

if (toDelete.length) {
const activeAssetOutputs = new Set(emittedAssets.values());

for (const deletedAsset of toDelete) {
if (!activeAssetOutputs.has(deletedAsset.path)) {
filesToDelete.add(deletedAsset.path);
}
}
}
}

/**
* Checks whether any modified file (excluding `package.json`) is part of the
* tracked compilation file set.
Expand Down
Loading
Loading