Skip to content
Merged
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
20 changes: 3 additions & 17 deletions src/builders/build/builder.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
Expand Down
12 changes: 12 additions & 0 deletions src/builders/build/setup-builder-env-variables.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
8 changes: 3 additions & 5 deletions src/builders/remote/builder.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions src/builders/remote/setup-builder-env-variables.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
15 changes: 1 addition & 14 deletions src/utils/federation-source-files.spec.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -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',
);
});
});
14 changes: 0 additions & 14 deletions src/utils/federation-source-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
);
}
153 changes: 153 additions & 0 deletions src/utils/ng-build-env-snapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return { useParallelTs: true, optimizeChunksThreshold: 3, maxWorkers: 4 };
}

function cacheWith(...snapshots: Record<string, unknown>[]) {
const cache: Record<string, { exports?: unknown }> = {};
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([]);
});
});
Loading
Loading