Skip to content

buildForFederation corrupts globalSharedCompilationState, causing "polyfills.ts missing from TypeScript compilation" on Linux #47

@luke-petro

Description

@luke-petro

Hi,

We were routinely receiving build errors on Azure DevOps (using ubuntu image) for our native-federation-v4 app:

Image

The confusing thing was, it if we retried the failed job, it would sometimes succeed, but we could never reproduce locally (all devs use mac or win). After some analysis with the help of Claude, we discovered a race condition was happening. Here are the findings:

Package: @angular-architects/native-federation-v4 v20.4.1
Angular: 20.x | @angular/build: (latest)

Summary

When buildForFederation runs before buildApplication, it inadvertently calls markAsReady() on Angular's internal globalSharedCompilationState singleton. This leaves #pendingCompilation = false when buildApplication starts. The polyfills bundle (NoopCompilation) then skips its wait for the browser bundle's AotCompilation to populate typeScriptFileCache, resulting in a hard build failure.

The bug is Linux-only (CI) and does not reproduce on macOS due to a difference in OS-level I/O scheduling (epoll vs kqueue).

Root Cause

Causal chain:

  1. buildForFederation calls bundleExposedAndMappings → createAngularEsbuildContext → createAwaitableCompilerPlugin, which wraps Angular's createCompilerPlugin from @angular/build/private.
  2. That plugin's onStart hook calls markAsInProgress() at the start and markAsReady() when done — leaving globalSharedCompilationState.#pendingCompilation = false after buildForFederation completes.
  3. This is the same singleton that buildApplication uses to coordinate its concurrent browser and polyfills bundles. buildForFederation is not supposed to interact with it at all.
  4. When buildApplication subsequently starts, BundlerContext.bundleAll fires two concurrent context.build() calls: - Browser bundle (AotCompilation): onStart calls markAsInProgress() synchronously, before any await - Polyfills bundle (NoopCompilation): onStart does await compilation.initialize() first (a microtask), then checks waitUntilReady
  5. On Linux (epoll + Go goroutine scheduling), the polyfills bundle's IPC callback arrives and resolves its microtask before the browser bundle's synchronous markAsInProgress() runs. It sees #pendingCompilation = false (the stale value from step 2), concludes compilation is already done, and proceeds — but typeScriptFileCache is empty, so polyfills.ts is reported as missing.
  6. On macOS (kqueue), the ordering consistently favours the browser bundle going first, so markAsInProgress() always runs before the polyfills bundle checks the flag. The race never triggers.

Workaround (applied via patch-package)

We've implemented a patch package workaround and the error has gone away. The solution was to reset the state immediately after buildForFederation completes, in src/builders/build/builder.js:

import { createRequire } from 'node:module';

// After buildForFederation / bundleExposedAndMappings completes (src/builders/build/builder.ts:349):
try {
    const req = createRequire(import.meta.url);
    const angularBuildDir = path.dirname(path.dirname(req.resolve('@angular/build')));
    const compilationStatePath = path.join(
        angularBuildDir,
        'src/tools/esbuild/angular/compilation-state.js'
    );
    req(compilationStatePath).getSharedCompilationState().markAsInProgress();
} catch (_e) {}

This resets #pendingCompilation = true so buildApplication manages the state from a clean slate.

Reproduction

Consistent on any Linux CI environment (tested on GitHub Actions ubuntu-latest). Does not reproduce on macOS.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions