From 2302837fdd6d875b1d9b10fd52b3fe8b663e9f3a Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Tue, 4 Aug 2026 10:13:24 +0200 Subject: [PATCH] fix(builder): re-apply NG_BUILD_* settings when @angular/build is already loaded The builder sets NG_BUILD_OPTIMIZE_CHUNKS=0 and NG_BUILD_PARALLEL_TS=0 before importing @angular/build, which snapshots both into module-level consts on first load. Nx requires @angular/build/private at module scope before it resolves the builder (to stub assertCompatibleAngularVersion), so under Nx the snapshot is taken with the defaults and both features stay on: production builds run the chunk-optimizer pass that breaks federated shared externals, and the parallel-TS path runs instead of the shared-cache one. replayNgBuildEnv re-applies the disabled values onto the cached exports, which works because both consumers re-read the property per call. It matches only environment-options.js in require.cache, so it is a no-op under the Angular CLI, and degrades to a warning if the exports change shape upstream. Opt out with NF_NG_BUILD_ENV_REPLAY=0. Also drops the diagnostics that existed to identify the compilation path from cache shape (the NG_BUILD_PARALLEL_TS log and describeFederationCache): the builder now reports the condition directly. Fixes #107. Fixes #114. --- README.md | 24 +++ src/builders/build/builder.ts | 20 +-- .../build/setup-builder-env-variables.ts | 12 ++ src/builders/remote/builder.ts | 8 +- .../remote/setup-builder-env-variables.ts | 9 ++ src/utils/federation-source-files.spec.ts | 15 +- src/utils/federation-source-files.ts | 14 -- src/utils/ng-build-env-snapshot.spec.ts | 153 ++++++++++++++++++ src/utils/ng-build-env-snapshot.ts | 104 ++++++++++++ 9 files changed, 309 insertions(+), 50 deletions(-) create mode 100644 src/utils/ng-build-env-snapshot.spec.ts create mode 100644 src/utils/ng-build-env-snapshot.ts diff --git a/README.md b/README.md index 13d4f85d..b342b15b 100644 --- a/README.md +++ b/README.md @@ -658,6 +658,30 @@ For this, there are several reasons: - Perhaps your shared packages contain some code esbuild cannot transfer to EcmaScript modules. This should not be the case for packages, built with the Angular CLI or Nx and the underlying package ng-packagr. If this happens, please let us know about the package causing troubles. +### Why do I get `ɵɵdefineComponent is not a function` in an Nx workspace? + +The builder disables two of Angular's build features by setting environment variables before `@angular/build` loads: + +- `NG_BUILD_OPTIMIZE_CHUNKS=0` — Angular's chunk optimization pass (on by default in Angular 22 for production builds, from 3 lazy chunks upwards) re-bundles the esbuild output _after_ Native Federation has computed its import map, so shared externals such as `@angular/core` are no longer resolved as singletons. At runtime that surfaces as `ɵɵdefineComponent is not a function`. +- `NG_BUILD_PARALLEL_TS=0` — lets the compilation steps share one cache, which is much faster here. + +`@angular/build` reads those variables **once**, when it is first loaded, so this only works if the builder is loaded first. Under the Angular CLI it is. Nx loads `@angular/build` before it resolves the builder (`nx/src/adapter/compat.js` requires `@angular/build/private` to stub a version assertion), so the variables arrive too late and both features stay on — see [#107](https://github.com/native-federation/angular-adapter/issues/107) / [#114](https://github.com/native-federation/angular-adapter/issues/114). + +The builder detects this and re-applies both settings to the already-loaded `@angular/build`, logging: + +``` +INFO @angular/build was already loaded when this builder started (Nx preloads it), + so its build environment was stale; re-applied useParallelTs=false, + optimizeChunksThreshold=Infinity. +``` + +That line is informational — it means the problem was corrected, and no action is needed. Two things worth knowing: + +- **Run one uncached build after upgrading** (`nx build my-app --skip-nx-cache`). Artifacts that Nx cached from a broken build are still replayed on a cache hit. +- If you would rather set the variables yourself, put them in a workspace-root `.env` file — Nx loads dotenv files before `@angular/build` — and add `{ "env": "NG_BUILD_OPTIMIZE_CHUNKS" }` to the target's `inputs` so the cache reacts to changes. Setting `NF_NG_BUILD_ENV_REPLAY=0` then keeps the builder from touching the loaded module at all. + +If the builder instead warns that it _could not_ re-apply a setting, `@angular/build` has changed internally: use the `.env` approach above and please report it. + ### How to deal with CommonJS Packages? The good message is, that the official Angular Package Format defines the usage of ECMA Script Modules (ESM) for years. This is the future-proof standard, Native Federation is built upon and all npm packages created with the Angular CLI follow. If you use older CommonJS-based packages, Native Federation automatically converts them to ESM. Depending on the package, this might change some details. Here, you find some [information for dealing with CommonJS packages](https://shorturl.at/jmzH0). diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 4c5bf282..6c705ff6 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -1,3 +1,5 @@ +// Must stay the first import: it sets NG_BUILD_* before @angular/build snapshots them, +// and its detection must not see this file's own @angular/build imports. import "./setup-builder-env-variables.js"; import * as fs from "fs"; @@ -46,10 +48,7 @@ import { import { type Plugin, type PluginBuild } from "esbuild"; import { devHostInstancesPlugin } from "../../plugin/dev-host-instances-plugin.js"; import { checkForInvalidImports } from "./../../utils/check-for-invalid-imports.js"; -import { - describeFederationCache, - federationSourceFiles, -} from "./../../utils/federation-source-files.js"; +import { federationSourceFiles } from "./../../utils/federation-source-files.js"; import { createStaleWatchEventFilter } from "./../../utils/stale-watch-event-filter.js"; import { federationBuildNotifier } from "./federation-build-notifier.js"; import type { NfBuilderSchema, NfInternalOptions } from "./schema.js"; @@ -300,16 +299,6 @@ export async function* runBuilder( const start = process.hrtime(); logger.measure(start, "To load the federation config."); - // Which TS compilation path the build takes is decided by module-load order: - // setup-builder-env-variables.ts sets NG_BUILD_PARALLEL_TS=0, but - // @angular/build captures it in a module-level const, so anything importing - // @angular/build first (common under Nx) wins. Pair this with the - // "SourceFileCache tracked files" line to see which path actually ran: - // outer=0 with typeScript>0 means the parallel path despite the env value. - logger.verbose( - `NG_BUILD_PARALLEL_TS=${process.env["NG_BUILD_PARALLEL_TS"] ?? "(unset)"}`, - ); - const externals = getExternals(normalized.config); // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the @@ -454,9 +443,6 @@ export async function* runBuilder( const staleEvents = createStaleWatchEventFilter(); const syncFederationWatcher = (): void => { if (!nfWatcher) return; - logger.verbose( - describeFederationCache(normalized.options.federationCache.bundlerCache), - ); const files = federationSourceFiles( normalized.options.federationCache.bundlerCache, ); diff --git a/src/builders/build/setup-builder-env-variables.ts b/src/builders/build/setup-builder-env-variables.ts index 08860c07..a3e5828c 100644 --- a/src/builders/build/setup-builder-env-variables.ts +++ b/src/builders/build/setup-builder-env-variables.ts @@ -1,3 +1,7 @@ +import { logger } from '@softarc/native-federation/internal'; + +import { replayNgBuildEnv } from '../../utils/ng-build-env-snapshot.js'; + /** * Disables Angular's parallel caching and allows for * a shared cache between the compilation steps which @@ -16,3 +20,11 @@ if (!process.env['NG_BUILD_PARALLEL_TS']) { * to Infinity, keeping federation's chunk layout intact. */ process.env['NG_BUILD_OPTIMIZE_CHUNKS'] = '0'; + +// The writes above are too late once @angular/build is loaded, as under Nx. +for (const { level, message } of replayNgBuildEnv([ + 'NG_BUILD_PARALLEL_TS', + 'NG_BUILD_OPTIMIZE_CHUNKS', +])) { + logger[level](message); +} diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 67dc6a55..db190266 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -1,3 +1,5 @@ +// Must stay the first import: it sets NG_BUILD_* before @angular/build snapshots them, +// and its detection must not see this file's own @angular/build imports. import './setup-builder-env-variables.js'; import * as path from 'path'; @@ -27,10 +29,7 @@ import { import { createAngularBuildAdapter } from '../../tools/esbuild/angular-esbuild-adapter.js'; import { checkForInvalidImports } from '../../utils/check-for-invalid-imports.js'; -import { - describeFederationCache, - federationSourceFiles -} from '../../utils/federation-source-files.js'; +import { federationSourceFiles } from '../../utils/federation-source-files.js'; import { createStaleWatchEventFilter } from '../../utils/stale-watch-event-filter.js'; import type { NfRemoteBuilderSchema, NfRemoteInternalOptions } from './schema.js'; @@ -153,7 +152,6 @@ export async function* runRemoteBuilder( // records it depends on the TS compilation path; see federationSourceFiles. const syncFederationWatcher = (): void => { if (!changeWatcher) return; - logger.verbose(describeFederationCache(normalized.options.federationCache.bundlerCache)); const files = federationSourceFiles(normalized.options.federationCache.bundlerCache); for (const file of files) staleEvents.seed(file); syncNfFileWatcher( diff --git a/src/builders/remote/setup-builder-env-variables.ts b/src/builders/remote/setup-builder-env-variables.ts index d04e45e6..08e597bb 100644 --- a/src/builders/remote/setup-builder-env-variables.ts +++ b/src/builders/remote/setup-builder-env-variables.ts @@ -1,3 +1,7 @@ +import { logger } from '@softarc/native-federation/internal'; + +import { replayNgBuildEnv } from '../../utils/ng-build-env-snapshot.js'; + /** * Disables Angular's parallel caching and allows for * a shared cache between the compilation steps which @@ -6,3 +10,8 @@ if (!process.env['NG_BUILD_PARALLEL_TS']) { process.env['NG_BUILD_PARALLEL_TS'] = '0'; } + +// The write above is too late once @angular/build is loaded, as under Nx. +for (const { level, message } of replayNgBuildEnv(['NG_BUILD_PARALLEL_TS'])) { + logger[level](message); +} diff --git a/src/utils/federation-source-files.spec.ts b/src/utils/federation-source-files.spec.ts index cbb346f9..305a4605 100644 --- a/src/utils/federation-source-files.spec.ts +++ b/src/utils/federation-source-files.spec.ts @@ -1,7 +1,7 @@ import { SourceFileCache } from '@angular/build/private'; import type ts from 'typescript'; -import { describeFederationCache, federationSourceFiles } from './federation-source-files.js'; +import { federationSourceFiles } from './federation-source-files.js'; function cacheWith(options: { outer?: readonly string[]; @@ -65,16 +65,3 @@ describe('federationSourceFiles', () => { expect(federationSourceFiles(cache)).toEqual(['/app/only.ts']); }); }); - -describe('describeFederationCache', () => { - it('reports the size of each tracking source', () => { - const cache = cacheWith({ - typeScript: ['/app/a.ts', '/app/b.ts'], - referenced: ['/app/a.html'], - }); - - expect(describeFederationCache(cache)).toBe( - 'SourceFileCache tracked files: outer=0, typeScript=2, referenced=1', - ); - }); -}); diff --git a/src/utils/federation-source-files.ts b/src/utils/federation-source-files.ts index f09ad6cb..1119837e 100644 --- a/src/utils/federation-source-files.ts +++ b/src/utils/federation-source-files.ts @@ -24,17 +24,3 @@ export function federationSourceFiles(cache: SourceFileCache): string[] { ]), ].filter((file) => !file.includes('node_modules')); } - -/** - * One-line fingerprint of where the cache tracked its files, for diagnosing - * which compilation path a dev server is on: a populated outer Map means - * in-process type checking (`NG_BUILD_PARALLEL_TS=0`); an empty outer Map - * alongside a populated `typeScriptFileCache` means the parallel-TS path. - */ -export function describeFederationCache(cache: SourceFileCache): string { - return ( - `SourceFileCache tracked files: outer=${cache.size}, ` + - `typeScript=${cache.typeScriptFileCache.size}, ` + - `referenced=${cache.referencedFiles?.length ?? 0}` - ); -} diff --git a/src/utils/ng-build-env-snapshot.spec.ts b/src/utils/ng-build-env-snapshot.spec.ts new file mode 100644 index 00000000..813d7a7f --- /dev/null +++ b/src/utils/ng-build-env-snapshot.spec.ts @@ -0,0 +1,153 @@ +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { type NgBuildEnvVariable, replayNgBuildEnv } from './ng-build-env-snapshot.js'; + +const CACHE_ID = path.join('/ws/node_modules/@angular/build/src/utils/environment-options.js'); + +/** The values @angular/build freezes when no NG_BUILD_* variable is set. */ +function defaultSnapshot(): Record { + return { useParallelTs: true, optimizeChunksThreshold: 3, maxWorkers: 4 }; +} + +function cacheWith(...snapshots: Record[]) { + const cache: Record = {}; + snapshots.forEach((exports, i) => { + cache[i === 0 ? CACHE_ID : CACHE_ID.replace('/ws/', `/ws/other-${i}/`)] = { exports }; + }); + return cache; +} + +const BOTH: NgBuildEnvVariable[] = ['NG_BUILD_PARALLEL_TS', 'NG_BUILD_OPTIMIZE_CHUNKS']; +/** What setup-builder-env-variables.ts has written by the time we run. */ +const ADAPTER_ENV = { NG_BUILD_PARALLEL_TS: '0', NG_BUILD_OPTIMIZE_CHUNKS: '0' }; + +describe('replayNgBuildEnv', () => { + it('does nothing when @angular/build has not been loaded (the Angular CLI path)', () => { + expect(replayNgBuildEnv(BOTH, {}, ADAPTER_ENV)).toEqual([]); + }); + + it('ignores other @angular/build modules', () => { + // The healthy path already has 4 of them cached; a coarse match would fire here. + const cache = { '/ws/node_modules/@angular/build/src/private.js': { exports: {} } }; + + expect(replayNgBuildEnv(BOTH, cache, ADAPTER_ENV)).toEqual([]); + }); + + it('skips a cache entry without exports', () => { + expect(replayNgBuildEnv(BOTH, { [CACHE_ID]: {} }, ADAPTER_ENV)).toEqual([]); + }); + + it('re-applies both variables onto a stale snapshot', () => { + const snapshot = defaultSnapshot(); + + const messages = replayNgBuildEnv(BOTH, cacheWith(snapshot), ADAPTER_ENV); + + expect(snapshot['useParallelTs']).toBe(false); + expect(snapshot['optimizeChunksThreshold']).toBe(Infinity); + expect(snapshot['maxWorkers']).toBe(4); + expect(messages).toHaveLength(1); + expect(messages[0]?.level).toBe('info'); + expect(messages[0]?.message).toContain('useParallelTs=false, optimizeChunksThreshold=Infinity'); + }); + + it('only touches the variables it was asked about', () => { + const snapshot = defaultSnapshot(); + + replayNgBuildEnv(['NG_BUILD_PARALLEL_TS'], cacheWith(snapshot), ADAPTER_ENV); + + expect(snapshot['useParallelTs']).toBe(false); + expect(snapshot['optimizeChunksThreshold']).toBe(3); + }); + + it('leaves the snapshot alone when the variable does not disable the feature', () => { + const snapshot = defaultSnapshot(); + + // A user who asked for parallel TS keeps it, exactly as under the CLI, where + // setup-builder-env-variables.ts leaves an already-set variable in place. + const messages = replayNgBuildEnv(BOTH, cacheWith(snapshot), { + NG_BUILD_PARALLEL_TS: '1', + NG_BUILD_OPTIMIZE_CHUNKS: '5', + }); + + expect(snapshot).toEqual(defaultSnapshot()); + expect(messages).toEqual([]); + }); + + it.each(['0', 'false', 'FALSE'])('accepts %s as "disabled"', value => { + const snapshot = defaultSnapshot(); + + replayNgBuildEnv(['NG_BUILD_PARALLEL_TS'], cacheWith(snapshot), { + NG_BUILD_PARALLEL_TS: value, + }); + + expect(snapshot['useParallelTs']).toBe(false); + }); + + it('says nothing when the snapshot already matches', () => { + const snapshot = { useParallelTs: false, optimizeChunksThreshold: Infinity }; + + expect(replayNgBuildEnv(BOTH, cacheWith(snapshot), ADAPTER_ENV)).toEqual([]); + }); + + it('patches every copy of @angular/build in the cache', () => { + const first = defaultSnapshot(); + const second = defaultSnapshot(); + + replayNgBuildEnv(['NG_BUILD_OPTIMIZE_CHUNKS'], cacheWith(first, second), ADAPTER_ENV); + + expect(first['optimizeChunksThreshold']).toBe(Infinity); + expect(second['optimizeChunksThreshold']).toBe(Infinity); + }); + + it('warns instead of throwing when upstream renamed the export', () => { + const messages = replayNgBuildEnv(BOTH, cacheWith({ useParallelTs: true }), ADAPTER_ENV); + + expect(messages.map(m => m.level)).toEqual(['info', 'warn']); + expect(messages[1]?.message).toContain( + 'optimizeChunksThreshold (expected a number, found undefined)' + ); + }); + + it('warns instead of throwing when upstream changed the type', () => { + const messages = replayNgBuildEnv( + ['NG_BUILD_OPTIMIZE_CHUNKS'], + cacheWith({ optimizeChunksThreshold: 'three' }), + ADAPTER_ENV + ); + + expect(messages[0]?.level).toBe('warn'); + expect(messages[0]?.message).toContain( + 'optimizeChunksThreshold (expected a number, found string)' + ); + }); + + it.each([ + ['an accessor', { get: () => 3 }], + ['read-only', { value: 3, writable: false }], + ])('warns instead of throwing when the export became %s', (_label, descriptor) => { + const snapshot = {}; + Object.defineProperty(snapshot, 'optimizeChunksThreshold', descriptor); + + const messages = replayNgBuildEnv( + ['NG_BUILD_OPTIMIZE_CHUNKS'], + cacheWith(snapshot), + ADAPTER_ENV + ); + + expect(messages[0]?.level).toBe('warn'); + expect(messages[0]?.message).toContain('optimizeChunksThreshold (not writable)'); + }); + + it.each(['0', 'false', 'FALSE'])('is disabled by NF_NG_BUILD_ENV_REPLAY=%s', value => { + const snapshot = defaultSnapshot(); + + const messages = replayNgBuildEnv(BOTH, cacheWith(snapshot), { + ...ADAPTER_ENV, + NF_NG_BUILD_ENV_REPLAY: value, + }); + + expect(snapshot).toEqual(defaultSnapshot()); + expect(messages).toEqual([]); + }); +}); diff --git a/src/utils/ng-build-env-snapshot.ts b/src/utils/ng-build-env-snapshot.ts new file mode 100644 index 00000000..1f150f52 --- /dev/null +++ b/src/utils/ng-build-env-snapshot.ts @@ -0,0 +1,104 @@ +import { createRequire } from 'node:module'; +import * as path from 'node:path'; + +export type NgBuildEnvVariable = 'NG_BUILD_PARALLEL_TS' | 'NG_BUILD_OPTIMIZE_CHUNKS'; + +export interface ReplayMessage { + level: 'info' | 'warn'; + message: string; +} + +// What `environment-options.js` derives from each variable when it disables the feature. +const DISABLED: Record = { + NG_BUILD_PARALLEL_TS: { property: 'useParallelTs', value: false }, + NG_BUILD_OPTIMIZE_CHUNKS: { property: 'optimizeChunksThreshold', value: Infinity }, +}; + +const ENV_OPTIONS_SUFFIX = path.join('@angular', 'build', 'src', 'utils', 'environment-options.js'); + +/** Escape hatch: `0`/`false` leaves an already-loaded @angular/build alone. */ +const OPT_OUT_VARIABLE = 'NF_NG_BUILD_ENV_REPLAY'; + +const OFF_VALUES = new Set(['0', 'false']); + +function isOff(value: string | undefined): boolean { + return value !== undefined && OFF_VALUES.has(value.toLowerCase()); +} + +type EnvLike = Record; +type CacheLike = Record; + +/** + * Re-applies the `NG_BUILD_*` variables the caller just disabled onto an + * `@angular/build` that was already loaded — see the README FAQ and #107 / #114. + * Nx requires `@angular/build/private` before resolving the builder, and + * `environment-options.js` snapshots the variables on first load, so the caller's + * writes land too late. Mutating the cached exports works because both consumers + * re-read the property per call; evicting the module does not, as they keep a + * reference to the stale namespace. + * + * Must run before this package imports `@angular/build`, or it detects our own + * load. No match means "not loaded yet" *or* "cannot see it" (ESM), not "healthy". + */ +export function replayNgBuildEnv( + variables: readonly NgBuildEnvVariable[], + cache: CacheLike = createRequire(import.meta.url).cache, + env: EnvLike = process.env +): ReplayMessage[] { + if (isOff(env[OPT_OUT_VARIABLE])) { + return []; + } + + const replayed: string[] = []; + const failed: string[] = []; + + for (const id of Object.keys(cache)) { + // The exact file, never `@angular/build`: 4 of its modules are already cached + // here on the healthy path, so a coarse match reports every CLI build as broken. + if (!id.endsWith(ENV_OPTIONS_SUFFIX)) continue; + const snapshot = cache[id]?.exports as Record | undefined; + if (!snapshot) continue; + + for (const variable of variables) { + const { property, value } = DISABLED[variable]; + if (!isOff(env[variable]) || snapshot[property] === value) continue; + + if (typeof snapshot[property] !== typeof value) { + failed.push(`${property} (expected a ${typeof value}, found ${typeof snapshot[property]})`); + continue; + } + try { + snapshot[property] = value; + } catch { + // Strict mode: assigning to a getter-only export throws. + } + if (snapshot[property] === value) { + replayed.push(`${property}=${value}`); + } else { + failed.push(`${property} (not writable)`); + } + } + } + + const messages: ReplayMessage[] = []; + if (replayed.length > 0) { + messages.push({ + level: 'info', + message: + `@angular/build was already loaded when this builder started (Nx preloads it), ` + + `so its build environment was stale; re-applied ${replayed.join(', ')}.`, + }); + } + if (failed.length > 0) { + messages.push({ + level: 'warn', + message: + `Could not re-apply ${failed.join(', ')} on the already-loaded @angular/build. Builds ` + + `may re-bundle federated chunks. Set NG_BUILD_OPTIMIZE_CHUNKS=0 and ` + + `NG_BUILD_PARALLEL_TS=0 in the environment (e.g. a workspace-root .env file) and ` + + `report this at https://github.com/native-federation/angular-adapter/issues.`, + }); + } + + return messages; +}