From 0622f56e7c602290146b55c31ecc8e763484b08e Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 26 Aug 2026 15:08:56 +0200 Subject: [PATCH 1/6] feat(builders): make watching npm-linked shared deps opt-in Core 4.5.0 gates linkedSharedDirs behind a new watchLinkedDeps federation option that defaults to false. Under pnpm's default linker every dependency is a symlink, so the npm-link watch added in 4.3.2 treated the whole graph as linked: startup stalled on checksum walks and an idle ng serve kept polling. Neither builder built that field into its options object, so linkedSharedDirs returned [] and nobody could turn the watch back on. Correctness never depended on the watch -- linked content signals still run unconditionally, so a rebuilt linked library re-bundles on the next build regardless. The option only buys live reload. Also supply watchpack as the watch implementation. Core's built-in poll is dependency-free but re-walks the tree every 300ms; watchpack is event-driven and survives having a watched directory swapped out, which is what `ng build ` does to dist and what a raw fs.watch silently dies on. Refs #130 --- package.json | 5 +- pnpm-lock.yaml | 86 +++++++++--------- src/builders/build/builder.ts | 3 + src/builders/build/schema.d.ts | 1 + src/builders/build/schema.json | 5 ++ src/builders/remote/builder.ts | 1 + src/builders/remote/change-watcher.ts | 2 + src/builders/remote/schema.d.ts | 1 + src/builders/remote/schema.json | 5 ++ src/utils/watchpack-watch.spec.ts | 123 ++++++++++++++++++++++++++ src/utils/watchpack-watch.ts | 41 +++++++++ 11 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 src/utils/watchpack-watch.spec.ts create mode 100644 src/utils/watchpack-watch.ts diff --git a/package.json b/package.json index dcfb635..cb00e2f 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,12 @@ "@angular-devkit/core": "~22.1.0", "@angular-devkit/schematics": "~22.1.0", "@chialab/esbuild-plugin-commonjs": "^0.19.0", - "@softarc/native-federation": "^4.4.0", + "@softarc/native-federation": "~4.5.0-next.1", "@softarc/native-federation-orchestrator": "^4.5.2", "es-module-shims": "^2.8.0", "esbuild": "^0.28.0", - "mrmime": "^2.0.1" + "mrmime": "^2.0.1", + "watchpack": "^2.5.2" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9353527..4f55cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^0.19.0 version: 0.19.1 '@softarc/native-federation': - specifier: ^4.4.0 - version: 4.4.1(typescript@6.0.3) + specifier: ~4.5.0-next.1 + version: 4.5.0-next.1(typescript@6.0.3) '@softarc/native-federation-orchestrator': specifier: ^4.5.2 version: 4.6.0 @@ -38,13 +38,16 @@ importers: mrmime: specifier: ^2.0.1 version: 2.0.1 + watchpack: + specifier: ^2.5.2 + version: 2.5.2 devDependencies: '@angular/build': specifier: ~22.1.0 - version: 22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0) + version: 22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(supports-color@7.2.0)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0) '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.9.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0)) '@nx/devkit': specifier: ^23.1.0 version: 23.1.1(nx@23.1.1) @@ -56,7 +59,7 @@ importers: version: 4.1.11(vitest@4.1.11) eslint: specifier: ^10.8.0 - version: 10.9.0(jiti@2.7.0) + version: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) globals: specifier: ^17.0.0 version: 17.11.0 @@ -77,7 +80,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.65.0 - version: 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vite: specifier: ^7.0.0 version: 7.3.6(@types/node@24.10.15)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.0)(yaml@2.9.0) @@ -1834,8 +1837,8 @@ packages: resolution: {integrity: sha512-8529SINGc3zBdKZcyUsP9vGj4EFjUOljy+43qfwzYSgC3FwkHcAjddf/p86t8kI9CmCp0JGeLkM1IjJ7xahVXQ==, tarball: https://registry.npmjs.org/@softarc/native-federation-orchestrator/-/native-federation-orchestrator-4.6.0.tgz} engines: {node: '>=24.16.0'} - '@softarc/native-federation@4.4.1': - resolution: {integrity: sha512-YPaWGbOHfl/Xs5HWQr9Ns3hUf056dA3qojsV2qEOYvNZ1JLYy9QC7Dgdvt6/CienxsLZnnymA3hHOMZs7jJvbA==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.4.1.tgz} + '@softarc/native-federation@4.5.0-next.1': + resolution: {integrity: sha512-HD8BO7b2d/9UIT4nSkq6D99mqB7DKQ35ONAkBpyx41/ojM98bvwzfJxL2UNtFvKqzlYaAeJZ/h0a5EagZ1IuZQ==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.5.0-next.1.tgz} '@softarc/sheriff-core@0.19.6': resolution: {integrity: sha512-KACxHG9sS7kNWgnnBODzdr14kMLMrJVlQKc+tViUP03p2fRwNhESOA49bz51Yn7dro1mbtMmmFjICLmZSJDZZA==, tarball: https://registry.npmjs.org/@softarc/sheriff-core/-/sheriff-core-0.19.6.tgz} @@ -3534,7 +3537,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0)': + '@angular/build@22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(supports-color@7.2.0)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2201.5(chokidar@5.0.0) @@ -3548,7 +3551,7 @@ snapshots: beasties: 0.4.3 browserslist: 4.28.8 esbuild: 0.28.2 - https-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0(supports-color@7.2.0) jsonc-parser: 3.3.1 listr2: 11.0.0 magic-string: 1.0.0 @@ -3741,7 +3744,6 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 - optional: true '@emnapi/core@1.4.5': dependencies: @@ -3756,7 +3758,6 @@ snapshots: '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 - optional: true '@emnapi/runtime@1.4.5': dependencies: @@ -3769,7 +3770,6 @@ snapshots: '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 - optional: true '@esbuild/aix-ppc64@0.28.2': optional: true @@ -3849,14 +3849,14 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@7.2.0) @@ -3872,9 +3872,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.9.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) '@eslint/object-schema@3.0.5': {} @@ -4116,8 +4116,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.4': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.9.0 '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': @@ -4675,7 +4675,7 @@ snapshots: dependencies: semver: 7.8.5 - '@softarc/native-federation@4.4.1(typescript@6.0.3)': + '@softarc/native-federation@4.5.0-next.1(typescript@6.0.3)': dependencies: '@softarc/sheriff-core': 0.19.6(typescript@6.0.3) chalk: 6.0.0 @@ -4721,15 +4721,15 @@ snapshots: dependencies: undici-types: 7.16.0 - '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/type-utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.67.0 - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4737,14 +4737,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(supports-color@7.2.0) - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4767,13 +4767,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) debug: 4.4.3(supports-color@7.2.0) - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -4796,13 +4796,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5237,11 +5237,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.0(jiti@2.7.0): + eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -5455,7 +5455,7 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@9.1.0: + https-proxy-agent@9.1.0(supports-color@7.2.0): dependencies: agent-base: 9.0.0 debug: 4.4.3(supports-color@7.2.0) @@ -6354,13 +6354,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.0(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 4180e54..eab0c8a 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -50,6 +50,7 @@ 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 { federationSourceFiles } from "./../../utils/federation-source-files.js"; +import { watchpackWatch } from "./../../utils/watchpack-watch.js"; import { federationBuildNotifier } from "./federation-build-notifier.js"; import { createFederationFreshness, @@ -289,6 +290,7 @@ export async function* runBuilder( tsConfig: federationTsConfig, verbose: ngBuilderOptions.verbose, watch: ngBuilderOptions.watch, + watchLinkedDeps: nfBuilderOptions.watchLinkedDeps === true, dev: !!nfBuilderOptions.dev, entryPoints, buildNotifications: nfBuilderOptions.buildNotifications, @@ -467,6 +469,7 @@ export async function* runBuilder( const nfWatcher: NfFileWatcher | undefined = watch ? createNfWatcher({ + watch: watchpackWatch, // Coalesce ng-packagr's atomic multi-write bursts into one rebuild. debounceMs: 100, onChange: (p) => { diff --git a/src/builders/build/schema.d.ts b/src/builders/build/schema.d.ts index c8c8be7..b26f291 100644 --- a/src/builders/build/schema.d.ts +++ b/src/builders/build/schema.d.ts @@ -11,6 +11,7 @@ export interface NfBuilderSchema extends JsonObject { buildNotifications?: BuildNotificationOptions; federationConfigPath?: string; watch?: boolean; + watchLinkedDeps?: boolean; skipHtmlTransform: boolean; esmsInitOptions: ESMSInitOptions; baseHref?: string; diff --git a/src/builders/build/schema.json b/src/builders/build/schema.json index f55cb85..29c59b2 100644 --- a/src/builders/build/schema.json +++ b/src/builders/build/schema.json @@ -18,6 +18,11 @@ "watch": { "type": "boolean" }, + "watchLinkedDeps": { + "type": "boolean", + "default": false, + "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. Correctness is not affected — a linked library still re-bundles on the next build either way." + }, "port": { "type": "number", "default": 0 diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 1a7e8e8..2caf23f 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -95,6 +95,7 @@ export async function* runRemoteBuilder( tsConfig: federationTsConfig, verbose: nfBuilderOptions.verbose, watch: nfBuilderOptions.watch, + watchLinkedDeps: nfBuilderOptions.watchLinkedDeps === true, dev: !!nfBuilderOptions.dev, entryPoints, cacheExternalArtifacts: nfBuilderOptions.cacheExternalArtifacts !== false, diff --git a/src/builders/remote/change-watcher.ts b/src/builders/remote/change-watcher.ts index c3f8871..a5b9fea 100644 --- a/src/builders/remote/change-watcher.ts +++ b/src/builders/remote/change-watcher.ts @@ -1,4 +1,5 @@ import { createNfWatcher, type NfFileWatcher } from '@softarc/native-federation/internal'; +import { watchpackWatch } from '../../utils/watchpack-watch.js'; export interface DebouncedChangeWatcher { watcher: NfFileWatcher; @@ -31,6 +32,7 @@ export function createDebouncedChangeWatcher( }; const watcher = createNfWatcher({ + watch: watchpackWatch, onChange: p => { pendingPaths.add(p); scheduleNotify(); diff --git a/src/builders/remote/schema.d.ts b/src/builders/remote/schema.d.ts index 517010e..394685d 100644 --- a/src/builders/remote/schema.d.ts +++ b/src/builders/remote/schema.d.ts @@ -7,6 +7,7 @@ export interface NfRemoteBuilderSchema extends JsonObject { dev: boolean; rebuildDelay: number; watch: boolean; + watchLinkedDeps?: boolean; outputPath?: string; projectName?: string; verbose?: boolean; diff --git a/src/builders/remote/schema.json b/src/builders/remote/schema.json index 4ea8a8f..75b51be 100644 --- a/src/builders/remote/schema.json +++ b/src/builders/remote/schema.json @@ -19,6 +19,11 @@ "type": "boolean", "default": false }, + "watchLinkedDeps": { + "type": "boolean", + "default": false, + "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. Correctness is not affected — a linked library still re-bundles on the next build either way." + }, "entryPoints": { "type": "array", "items": { "type": "string" }, diff --git a/src/utils/watchpack-watch.spec.ts b/src/utils/watchpack-watch.spec.ts new file mode 100644 index 0000000..9cdd55d --- /dev/null +++ b/src/utils/watchpack-watch.spec.ts @@ -0,0 +1,123 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { watchpackWatch } from './watchpack-watch'; + +// Watchpack is event-driven, but the events still cross a real fs boundary, so every +// assertion waits for a condition rather than a fixed delay. +const waitFor = async ( + predicate: () => boolean, + timeoutMs = 8000 +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise(r => setTimeout(r, 25)); + } + throw new Error('timed out waiting for a watch event'); +}; + +// Watchpack only reports changes newer than the watch's start time, and mtime +// granularity on some filesystems is a full second. +const settle = () => new Promise(r => setTimeout(r, 300)); + +describe('watchpackWatch', () => { + let root: string; + const handles: { close(): void }[] = []; + + const watch = ( + target: string, + opts: { recursive: boolean; poll?: { intervalMs: number } } + ): string[] => { + const seen: string[] = []; + handles.push(watchpackWatch(target, opts, f => f && seen.push(f))); + return seen; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-watchpack-')); + }); + + afterEach(() => { + for (const h of handles.splice(0)) h.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reports a changed file relative to the watched directory', async () => { + fs.writeFileSync(path.join(root, 'a.ts'), 'one'); + const seen = watch(root, { recursive: false }); + await settle(); + + fs.writeFileSync(path.join(root, 'a.ts'), 'two'); + await waitFor(() => seen.includes('a.ts')); + }); + + it('reports a file added after the watch started', async () => { + const seen = watch(root, { recursive: false }); + await settle(); + + fs.writeFileSync(path.join(root, 'new.ts'), 'hello'); + await waitFor(() => seen.includes('new.ts')); + }); + + it('does not descend below depth 1 when not recursive', async () => { + const nested = path.join(root, 'sub'); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, 'deep.ts'), 'one'); + const seen = watch(root, { recursive: false }); + await settle(); + + fs.writeFileSync(path.join(nested, 'deep.ts'), 'two'); + // Nothing to wait for, so give the event the same budget a real one would need. + await settle(); + expect(seen.filter(f => f.includes('deep.ts'))).toEqual([]); + }); + + it('reports nested changes when recursive', async () => { + const nested = path.join(root, 'sub'); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, 'deep.ts'), 'one'); + const seen = watch(root, { recursive: true }); + await settle(); + + fs.writeFileSync(path.join(nested, 'deep.ts'), 'two'); + await waitFor(() => seen.some(f => f.endsWith('deep.ts'))); + }); + + it('ignores node_modules under the watched root', async () => { + const modules = path.join(root, 'node_modules', 'pkg'); + fs.mkdirSync(modules, { recursive: true }); + fs.writeFileSync(path.join(modules, 'index.js'), 'one'); + const seen = watch(root, { recursive: true }); + await settle(); + + fs.writeFileSync(path.join(modules, 'index.js'), 'two'); + await settle(); + expect(seen.filter(f => f.includes('node_modules'))).toEqual([]); + }); + + // What `ng build my-lib` does to dist: the whole directory is replaced, not + // rewritten in place. A raw recursive fs.watch misses this and then dies. + it('survives the watched directory being swapped out', async () => { + const dist = path.join(root, 'dist'); + fs.mkdirSync(dist); + fs.writeFileSync(path.join(dist, 'main.js'), 'one'); + + const seen = watch(dist, { recursive: true, poll: { intervalMs: 100 } }); + await settle(); + + const replacement = path.join(root, 'dist-next'); + fs.mkdirSync(replacement); + fs.writeFileSync(path.join(replacement, 'main.js'), 'two'); + fs.rmSync(dist, { recursive: true, force: true }); + fs.renameSync(replacement, dist); + + await waitFor(() => seen.some(f => f.endsWith('main.js'))); + + // And the watch is still live afterwards, which is the half fs.watch loses. + seen.length = 0; + fs.writeFileSync(path.join(dist, 'main.js'), 'three'); + await waitFor(() => seen.some(f => f.endsWith('main.js'))); + }); +}); diff --git a/src/utils/watchpack-watch.ts b/src/utils/watchpack-watch.ts new file mode 100644 index 0000000..580640a --- /dev/null +++ b/src/utils/watchpack-watch.ts @@ -0,0 +1,41 @@ +import * as path from 'node:path'; +import Watchpack from 'watchpack'; +import type { WatchPort } from '@softarc/native-federation/internal'; + +/** + * Watchpack-backed replacement for core's built-in watch, which is dependency-free + * but re-walks the tree every poll interval — around a quarter of a core on a large + * checkout. Watchpack is event-driven and already survives the directory swap that + * `ng build ` does to `dist`, which a raw `fs.watch` does not. + */ +export const watchpackWatch: WatchPort['watch'] = (watchPath, opts, onEvent) => { + const root = path.resolve(watchPath); + + const watchpack = new Watchpack({ + // Core debounces and drops replays itself; batching again here only adds latency. + aggregateTimeout: 0, + followSymlinks: false, + poll: opts.poll ? opts.poll.intervalMs : false, + ignored: entry => { + const rel = path.relative(root, entry); + if (!rel || rel.startsWith('..')) return false; + const segments = rel.split(path.sep); + if (segments.includes('node_modules')) return true; + // Watchpack always descends. Leaving depth 1 visible keeps a non-recursive + // watch reporting entries added or removed directly under `root` — ignoring + // everything below is what stops it going deeper than fs.watch would. + return !opts.recursive && segments.length > 1; + }, + }); + + const emit = (entry: string): void => { + const rel = path.relative(root, entry); + if (rel) onEvent(rel); + }; + + watchpack.on('change', emit); + watchpack.on('remove', emit); + watchpack.watch({ directories: [root], startTime: Date.now() }); + + return { close: () => watchpack.close() }; +}; From 6a7a883ccb0dfec514c4dd8edbb0a8ff1ef138fe Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 26 Aug 2026 15:09:02 +0200 Subject: [PATCH 2/6] docs: document the opt-in watch for npm-linked shared libraries Detection now keys off a symlink whose real path resolves outside every node_modules tree, so a package manager's own symlinks no longer count as linked, and watching is opt-in via watchLinkedDeps. Also spell out that the option does not gate correctness, and that preserveSymlinks is not the switch for it -- it changes esbuild's resolution and invites duplicate singletons. Refs #130 --- README.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 912b582..ac2566c 100644 --- a/README.md +++ b/README.md @@ -641,11 +641,19 @@ A common local-development setup is to build a shared library in its own repo (w Since Native Federation shares such a library as an _external_ (it is excluded from Angular's own build), a plain `ng serve` used to ignore edits to it — the change lived under `node_modules`, which the build watcher skips, so you had to clear the cache or restart the dev server to see it. Since version `22.0.6` (requires `@softarc/native-federation` ≥ `4.3.2`), the adapter detects linked shared packages and re-bundles them automatically on change. +Two things about that detection have since changed (requires `@softarc/native-federation` ≥ `4.5.0`): + +- **A symlink alone no longer counts as linked.** Package managers that symlink by default — pnpm's default `isolated` linker, or Yarn's `nodeLinker: pnpm` — make _every_ dependency a symlink, which used to put the whole dependency graph on the watch list. A package is now treated as a live checkout only when its real path resolves **outside** every `node_modules` tree, which is exactly what `npm link` produces and what a package manager's internal symlink does not. +- **Watching linked packages is opt-in**, via the `watchLinkedDeps` builder option below. It is off by default because watching means polling the linked checkout for as long as the dev server runs. + +Turning the option off does **not** risk a stale bundle. The adapter still checks the content of every linked package on each build, so a rebuilt library is never served from cache — it re-bundles on the next build either way. What the option buys is the live-reload loop: picking the change up _without_ you triggering a build. + #### Requirements - The library is listed in your `federation.config.*` `shared` section (via `shareAll`, an explicit `shared` entry, or `sharedMappings`). -- Its package directory under `node_modules` is a **symlink** — i.e. it was linked with `npm link` (or your package manager's equivalent), not installed from a registry. +- Its package directory under `node_modules` is a **symlink pointing outside `node_modules`** — i.e. it was linked with `npm link` (or your package manager's equivalent), not installed from a registry and not symlinked by your package manager's own linker. - The library is rebuilt on change so the symlink target actually updates. With an Angular library this means running `ng build --watch` (ng-packagr) in the library's repo. +- `watchLinkedDeps` is set to `true` on the builder target you are running — see below. #### Workflow @@ -663,13 +671,31 @@ npm link @my-scope/my-lib ng serve ``` +Watching is opt-in, so enable it on the target you serve with: + +```json +"serve": { + "builder": "@angular-architects/native-federation:build", + "options": { + "target": "host:serve-original:development", + "watchLinkedDeps": true + } +} +``` + +The option is available on both the `:build` and `:remote` builders and defaults to `false`. + Now edit a source file in the library. ng-packagr rebuilds its `dist/`, and the adapter picks up the change, re-bundles the affected shared external, and logs `Done!` — no manual cache clear or dev-server restart needed. To also refresh the browser automatically when the rebuild finishes, enable SSE-based reloading as described in [Shell reloading when MFE finishes building for local development](#shell-reloading-when-mfe-finishes-building-for-local-development) (`initFederation(manifest, { sse: true })`). Otherwise, a manual browser refresh will show the update. #### How it works -The adapter resolves the real path of each symlinked shared package and adds it to the federation file watcher. Because linked packages live under `node_modules`, they are watched via polling, and a short debounce coalesces ng-packagr's atomic multi-file writes into a single rebuild. Only the shared externals affected by the change are re-bundled; regular (registry-installed) dependencies keep the version-only cache fast path, so there is no rebuild churn or performance regression for non-linked packages. +The adapter resolves the real path of each shared package and treats it as a live checkout when that path lies outside every `node_modules` tree. With `watchLinkedDeps` enabled, those directories are added to the federation file watcher; a short debounce coalesces ng-packagr's atomic multi-file writes into a single rebuild. Only the shared externals affected by the change are re-bundled; regular (registry-installed) dependencies keep the version-only cache fast path, so there is no rebuild churn or performance regression for non-linked packages. + +Watching a linked checkout polls it, which is why it is off by default: a registry dependency is bundled once and cached by checksum, so its bytes cannot change without its version changing, and watching it could never change an outcome. Only a linked checkout changes content under a fixed version. Angular draws the same line — it ignores `**/node_modules/**` unless you opt in. + +Note that `preserveSymlinks` is unrelated and is **not** the switch for this. It changes esbuild's module resolution rather than what gets watched, and turning it on is the classic route to loading two copies of a singleton like `@angular/core`. ## FAQ From 49f3693f18ce6d0c75c0af611a0f0a2894eaa6f0 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 28 Aug 2026 08:13:20 +0200 Subject: [PATCH 3/6] docs: correct the watchpack and preserveSymlinks rationale The port's JSDoc claimed watchpack replaces a per-interval tree walk, but core always passes `{ poll: true }` for linked dirs and watchpack then does the same readdir+lstat sweep per directory. Its real advantage is surviving the `dist` swap `ng build ` does, which is what the comment now says. `aggregateTimeout: 0` only gated the `aggregated` event, which nothing subscribes to; `change`/`remove` fire synchronously either way. Dropped. Documented why `poll` must be forwarded (without it the swap is reported once and the watch dies on the deleted inode) and why `followSymlinks` stays false while Angular threads `preserveSymlinks` into it. The README claimed `preserveSymlinks` is unrelated to this. Angular in fact uses it as exactly this switch, skipping its `**/node_modules/**` ignore so `npm link` keeps working; it overloads one flag because its resolver picks the path esbuild sees. Keep the guidance, fix the reason - and note that turning it on would shrink the watch set, since paths with a node_modules segment are skipped. --- README.md | 6 ++++-- src/utils/watchpack-watch.ts | 16 +++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ac2566c..8d57015 100644 --- a/README.md +++ b/README.md @@ -693,9 +693,11 @@ To also refresh the browser automatically when the rebuild finishes, enable SSE- The adapter resolves the real path of each shared package and treats it as a live checkout when that path lies outside every `node_modules` tree. With `watchLinkedDeps` enabled, those directories are added to the federation file watcher; a short debounce coalesces ng-packagr's atomic multi-file writes into a single rebuild. Only the shared externals affected by the change are re-bundled; regular (registry-installed) dependencies keep the version-only cache fast path, so there is no rebuild churn or performance regression for non-linked packages. -Watching a linked checkout polls it, which is why it is off by default: a registry dependency is bundled once and cached by checksum, so its bytes cannot change without its version changing, and watching it could never change an outcome. Only a linked checkout changes content under a fixed version. Angular draws the same line — it ignores `**/node_modules/**` unless you opt in. +Watching a linked checkout polls it, which is why it is off by default: a registry dependency is bundled once and cached by checksum, so its bytes cannot change without its version changing, and watching it could never change an outcome. Only a linked checkout changes content under a fixed version. -Note that `preserveSymlinks` is unrelated and is **not** the switch for this. It changes esbuild's module resolution rather than what gets watched, and turning it on is the classic route to loading two copies of a singleton like `@angular/core`. +Angular draws the same line but wires it to `preserveSymlinks`: when it watches the project root it ignores `**/node_modules/**`, and skips that ignore when `preserveSymlinks` is on, precisely so `npm link` keeps working. It has to overload one flag because its resolver decides which path esbuild sees. The adapter does not, because it resolves each shared package's real path itself — so `watchLinkedDeps` governs watching and nothing else. + +That separation is worth keeping, so do **not** reach for `preserveSymlinks` here. It changes module resolution: it is the classic route to loading two copies of a singleton like `@angular/core`, and under pnpm it makes every dependency resolve through `.pnpm`. It would also shrink the watch set rather than grow it, since the adapter skips any path with a `node_modules` segment and `preserveSymlinks` is exactly what makes a linked library's files report as `node_modules/@my-scope/my-lib/…` instead of their real location. ## FAQ diff --git a/src/utils/watchpack-watch.ts b/src/utils/watchpack-watch.ts index 580640a..f11880c 100644 --- a/src/utils/watchpack-watch.ts +++ b/src/utils/watchpack-watch.ts @@ -3,27 +3,25 @@ import Watchpack from 'watchpack'; import type { WatchPort } from '@softarc/native-federation/internal'; /** - * Watchpack-backed replacement for core's built-in watch, which is dependency-free - * but re-walks the tree every poll interval — around a quarter of a core on a large - * checkout. Watchpack is event-driven and already survives the directory swap that - * `ng build ` does to `dist`, which a raw `fs.watch` does not. + * Watchpack-backed replacement for core's built-in watch. Event-driven like `fs.watch` + * for a source directory, but it survives the `dist` swap `ng build ` does, which + * `fs.watch` reports once before dying on the deleted inode. */ export const watchpackWatch: WatchPort['watch'] = (watchPath, opts, onEvent) => { const root = path.resolve(watchPath); const watchpack = new Watchpack({ - // Core debounces and drops replays itself; batching again here only adds latency. - aggregateTimeout: 0, + // Core hands us realpaths; following would only descend into .pnpm. followSymlinks: false, + // Don't drop: watchpack only re-covers a swapped directory while polling. poll: opts.poll ? opts.poll.intervalMs : false, ignored: entry => { const rel = path.relative(root, entry); if (!rel || rel.startsWith('..')) return false; const segments = rel.split(path.sep); if (segments.includes('node_modules')) return true; - // Watchpack always descends. Leaving depth 1 visible keeps a non-recursive - // watch reporting entries added or removed directly under `root` — ignoring - // everything below is what stops it going deeper than fs.watch would. + // Watchpack always descends; ignoring below depth 1 caps a non-recursive + // watch at what fs.watch would report. return !opts.recursive && segments.length > 1; }, }); From 87b70a71d87e5162a3aaa8129c5103c868eb4126 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 28 Aug 2026 09:20:17 +0200 Subject: [PATCH 4/6] docs: correct what watchLinkedDeps off actually gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three spots claimed more than the code does. The depth-1 `ignored` predicate does not stop watchpack descending — it only collapses a deeper change onto its depth-1 ancestor, which core's file watch then drops as untracked. fs.watch reports nothing there; this reports the ancestor. The test asserted only that no nested path appeared, so it passed while the ancestor event fired unremarked; it now pins that behaviour from two levels down, where a parent mtime cannot explain it. "Correctness is not affected" held for a cold build but not inside ng serve: rebuildAffectedExternals returns before bundleShared when no modified file falls under the package dir, so a JavaScript-only change can stay stale until a restart. And off is not "no reload" either — a library's .d.ts is a TypeScript input resolved outside node_modules, so an ng-packagr rebuild is already picked up without the option. --- README.md | 6 ++++-- src/builders/build/schema.json | 2 +- src/builders/remote/schema.json | 2 +- src/utils/watchpack-watch.spec.ts | 13 ++++++++----- src/utils/watchpack-watch.ts | 5 +++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8d57015..404e780 100644 --- a/README.md +++ b/README.md @@ -646,14 +646,16 @@ Two things about that detection have since changed (requires `@softarc/native-fe - **A symlink alone no longer counts as linked.** Package managers that symlink by default — pnpm's default `isolated` linker, or Yarn's `nodeLinker: pnpm` — make _every_ dependency a symlink, which used to put the whole dependency graph on the watch list. A package is now treated as a live checkout only when its real path resolves **outside** every `node_modules` tree, which is exactly what `npm link` produces and what a package manager's internal symlink does not. - **Watching linked packages is opt-in**, via the `watchLinkedDeps` builder option below. It is off by default because watching means polling the linked checkout for as long as the dev server runs. -Turning the option off does **not** risk a stale bundle. The adapter still checks the content of every linked package on each build, so a rebuilt library is never served from cache — it re-bundles on the next build either way. What the option buys is the live-reload loop: picking the change up _without_ you triggering a build. +Turning the option off does **not** mean edits to a linked library are ignored. A library's type declarations are a TypeScript input, and the adapter resolves them to their real path outside `node_modules` — so an ng-packagr rebuild, which rewrites `dist/*.d.ts` alongside the JavaScript, is already noticed and re-bundled with the option off. What the option adds is coverage of changes that touch no such input: a JavaScript-only edit, or a rebuild whose emitted types come out byte-identical. + +A cold `ng build` re-bundles a changed linked library either way, since the adapter checksums each linked package's content on every build. A running `ng serve` is weaker: with the option off, a change touching no watched input can stay stale until you restart the server or run a build. #### Requirements - The library is listed in your `federation.config.*` `shared` section (via `shareAll`, an explicit `shared` entry, or `sharedMappings`). - Its package directory under `node_modules` is a **symlink pointing outside `node_modules`** — i.e. it was linked with `npm link` (or your package manager's equivalent), not installed from a registry and not symlinked by your package manager's own linker. - The library is rebuilt on change so the symlink target actually updates. With an Angular library this means running `ng build --watch` (ng-packagr) in the library's repo. -- `watchLinkedDeps` is set to `true` on the builder target you are running — see below. +- For the changes an ng-packagr rebuild does not cover on its own — see above — `watchLinkedDeps` is set to `true` on the builder target you are running. #### Workflow diff --git a/src/builders/build/schema.json b/src/builders/build/schema.json index 29c59b2..c78af2e 100644 --- a/src/builders/build/schema.json +++ b/src/builders/build/schema.json @@ -21,7 +21,7 @@ "watchLinkedDeps": { "type": "boolean", "default": false, - "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. Correctness is not affected — a linked library still re-bundles on the next build either way." + "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. A cold build re-bundles a changed linked library either way; this option covers the changes a running dev server would otherwise miss." }, "port": { "type": "number", diff --git a/src/builders/remote/schema.json b/src/builders/remote/schema.json index 75b51be..3f6787c 100644 --- a/src/builders/remote/schema.json +++ b/src/builders/remote/schema.json @@ -22,7 +22,7 @@ "watchLinkedDeps": { "type": "boolean", "default": false, - "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. Correctness is not affected — a linked library still re-bundles on the next build either way." + "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. A cold build re-bundles a changed linked library either way; this option covers the changes a running dev server would otherwise miss." }, "entryPoints": { "type": "array", diff --git a/src/utils/watchpack-watch.spec.ts b/src/utils/watchpack-watch.spec.ts index 9cdd55d..64f4ecd 100644 --- a/src/utils/watchpack-watch.spec.ts +++ b/src/utils/watchpack-watch.spec.ts @@ -61,16 +61,19 @@ describe('watchpackWatch', () => { await waitFor(() => seen.includes('new.ts')); }); - it('does not descend below depth 1 when not recursive', async () => { - const nested = path.join(root, 'sub'); - fs.mkdirSync(nested); + // Watchpack descends whatever it is handed, so `recursive: false` cannot stop it the + // way fs.watch does — it only caps the reported path at depth 1. Two levels down, and + // modifying an existing file so no parent directory's mtime moves: the only way 'sub' + // can be reported is watchpack having descended to it. + it('collapses a change below depth 1 onto its depth-1 ancestor when not recursive', async () => { + const nested = path.join(root, 'sub', 'deeper'); + fs.mkdirSync(nested, { recursive: true }); fs.writeFileSync(path.join(nested, 'deep.ts'), 'one'); const seen = watch(root, { recursive: false }); await settle(); fs.writeFileSync(path.join(nested, 'deep.ts'), 'two'); - // Nothing to wait for, so give the event the same budget a real one would need. - await settle(); + await waitFor(() => seen.includes('sub')); expect(seen.filter(f => f.includes('deep.ts'))).toEqual([]); }); diff --git a/src/utils/watchpack-watch.ts b/src/utils/watchpack-watch.ts index f11880c..06d33d2 100644 --- a/src/utils/watchpack-watch.ts +++ b/src/utils/watchpack-watch.ts @@ -20,8 +20,9 @@ export const watchpackWatch: WatchPort['watch'] = (watchPath, opts, onEvent) => if (!rel || rel.startsWith('..')) return false; const segments = rel.split(path.sep); if (segments.includes('node_modules')) return true; - // Watchpack always descends; ignoring below depth 1 caps a non-recursive - // watch at what fs.watch would report. + // Watchpack descends either way; this only collapses a deeper change onto its + // depth-1 ancestor. fs.watch would report nothing at all, but core's only + // non-recursive watch drops the collapsed path as an untracked file anyway. return !opts.recursive && segments.length > 1; }, }); From 1312adc424c9899fd7d0c4999a72d98f17af1b65 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 28 Aug 2026 09:53:17 +0200 Subject: [PATCH 5/6] feat(builders): name npm-linked shared packages the watch is skipping watchLinkedDeps defaults to false, so anyone who npm-links a shared library loses the reload that shipped in 22.0.6 with nothing said. That reads as a broken build rather than a default, since a linked library and a registry one look identical from the host. A watching build now names what it found once at startup. linkedSharedDirs returns [] when the option is off, so ask it again with the option forced on: the walk behind it is findDepPackageJson + realpath + lstat per shared key, not the recursive content walk that made #130 slow. Silent when nothing is linked, when the option is already on, and for a package manager's own symlinks -- a pnpm tree stays quiet. --- README.md | 6 +++ src/builders/build/builder.ts | 2 + src/builders/remote/builder.ts | 2 + src/utils/linked-deps-hint.spec.ts | 79 ++++++++++++++++++++++++++++++ src/utils/linked-deps-hint.ts | 35 +++++++++++++ 5 files changed, 124 insertions(+) create mode 100644 src/utils/linked-deps-hint.spec.ts create mode 100644 src/utils/linked-deps-hint.ts diff --git a/README.md b/README.md index 404e780..4bdb51c 100644 --- a/README.md +++ b/README.md @@ -650,6 +650,12 @@ Turning the option off does **not** mean edits to a linked library are ignored. A cold `ng build` re-bundles a changed linked library either way, since the adapter checksums each linked package's content on every build. A running `ng serve` is weaker: with the option off, a change touching no watched input can stay stale until you restart the server or run a build. +So that the default is never a silent surprise, a watching build that finds a linked shared package while the option is off says so once at startup: + +``` +INFO Detected npm-linked shared packages: @my-scope/my-lib. Set 'watchLinkedDeps' to true on this target to rebuild when they change. +``` + #### Requirements - The library is listed in your `federation.config.*` `shared` section (via `shareAll`, an explicit `shared` entry, or `sharedMappings`). diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index eab0c8a..0a7dea6 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -50,6 +50,7 @@ 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 { federationSourceFiles } from "./../../utils/federation-source-files.js"; +import { hintUnwatchedLinkedDeps } from "./../../utils/linked-deps-hint.js"; import { watchpackWatch } from "./../../utils/watchpack-watch.js"; import { federationBuildNotifier } from "./federation-build-notifier.js"; import { @@ -313,6 +314,7 @@ export async function* runBuilder( // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the // syncNfFileWatcher calls below a no-op) so the linked lib's real source is watched. const linkedDirs = linkedSharedDirs(normalized.config, normalized.options); + if (watch) hintUnwatchedLinkedDeps(normalized.config, normalized.options); const plugins = [ { diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 2caf23f..dc2988b 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -31,6 +31,7 @@ import { import { createAngularBuildAdapter } from '../../tools/esbuild/angular-esbuild-adapter.js'; import { checkForInvalidImports } from '../../utils/check-for-invalid-imports.js'; import { federationSourceFiles } from '../../utils/federation-source-files.js'; +import { hintUnwatchedLinkedDeps } from '../../utils/linked-deps-hint.js'; import type { NfRemoteBuilderSchema, NfRemoteInternalOptions } from './schema.js'; import { resolveNgBuilderOptions } from './resolve-ng-options.js'; @@ -115,6 +116,7 @@ export async function* runRemoteBuilder( // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the // syncNfFileWatcher calls below a no-op) so the linked lib's real source is watched. const linkedDirs = linkedSharedDirs(normalized.config, normalized.options); + if (nfBuilderOptions.watch) hintUnwatchedLinkedDeps(normalized.config, normalized.options); const assetEntries = normalizeRemoteAssetEntries( nfBuilderOptions.assets, diff --git a/src/utils/linked-deps-hint.spec.ts b/src/utils/linked-deps-hint.spec.ts new file mode 100644 index 0000000..528ce60 --- /dev/null +++ b/src/utils/linked-deps-hint.spec.ts @@ -0,0 +1,79 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { logger } from '@softarc/native-federation/internal'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hintUnwatchedLinkedDeps } from './linked-deps-hint.js'; + +type Args = Parameters; + +describe('hintUnwatchedLinkedDeps', () => { + let root: string; + let info: ReturnType; + + // A workspace whose node_modules entry for `key` is a symlink; `outside` decides + // whether its real path escapes node_modules, which is what core calls a linked + // checkout as opposed to a package manager's own symlink. + const workspace = (key: string, outside: boolean): void => { + const real = outside + ? path.join(root, 'checkouts', key) + : path.join(root, 'node_modules', '.store', key); + fs.mkdirSync(real, { recursive: true }); + fs.writeFileSync(path.join(real, 'package.json'), JSON.stringify({ name: key, version: '1.0.0' })); + + const link = path.join(root, 'node_modules', key); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(real, link, 'dir'); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'host' })); + }; + + const call = (key: string, watchLinkedDeps: boolean): void => + hintUnwatchedLinkedDeps({ shared: { [key]: {} } } as unknown as Args[0], { + workspaceRoot: root, + packageJson: path.join(root, 'package.json'), + watchLinkedDeps, + } as unknown as Args[1]); + + beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nf-hint-'))); + info = vi.spyOn(logger, 'info').mockImplementation(() => undefined); + }); + + afterEach(() => { + info.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('names the linked package when the option is off', () => { + workspace('my-lib', true); + call('my-lib', false); + + expect(info).toHaveBeenCalledTimes(1); + const message = String(info.mock.calls[0][0]); + expect(message).toContain('my-lib'); + expect(message).toContain('watchLinkedDeps'); + }); + + it('stays quiet when the option is already on', () => { + workspace('my-lib', true); + call('my-lib', true); + + expect(info).not.toHaveBeenCalled(); + }); + + // The #130 shape: pnpm's default linker symlinks every dependency, so a hint keyed + // on "is a symlink" would fire for the whole dependency graph. + it('stays quiet for a symlink that resolves inside node_modules', () => { + workspace('my-lib', false); + call('my-lib', false); + + expect(info).not.toHaveBeenCalled(); + }); + + it('stays quiet when nothing is linked', () => { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'host' })); + call('my-lib', false); + + expect(info).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/linked-deps-hint.ts b/src/utils/linked-deps-hint.ts new file mode 100644 index 0000000..a89439b --- /dev/null +++ b/src/utils/linked-deps-hint.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { linkedSharedDirs, logger } from '@softarc/native-federation/internal'; + +type LinkedSharedDirsArgs = Parameters; + +function packageNameOf(dir: string): string { + try { + const name = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')).name; + return typeof name === 'string' ? name : dir; + } catch { + return dir; + } +} + +/** + * Without this the `watchLinkedDeps` default reads as a broken build: linking a library + * and getting no reload looks the same as the feature not working. Only worth saying + * while watching, and only when turning the option on would actually change something — + * so ask `linkedSharedDirs` with it forced on, since it returns `[]` when it is off. + */ +export function hintUnwatchedLinkedDeps( + config: LinkedSharedDirsArgs[0], + options: LinkedSharedDirsArgs[1] +): void { + if (options.watchLinkedDeps) return; + + const dirs = linkedSharedDirs(config, { ...options, watchLinkedDeps: true }); + if (dirs.length === 0) return; + + logger.info( + `Detected npm-linked shared packages: ${dirs.map(packageNameOf).join(', ')}. ` + + `Set 'watchLinkedDeps' to true on this target to rebuild when they change.` + ); +} From 6b21b30084c30acadb4bbc0c2fcad6624a269980 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Sat, 29 Aug 2026 08:28:33 +0200 Subject: [PATCH 6/6] refactor(builders): move the npm-linked watch hint into core The hint lived here and was called from both builders, which meant every other adapter had to reimplement it. Core now emits it from buildForFederation, so the builders drop their call and this workspace keeps no copy of the logic. The message loses "on this target" with the move; core has no notion of targets. --- README.md | 2 +- src/builders/build/builder.ts | 2 - src/builders/remote/builder.ts | 2 - src/utils/linked-deps-hint.spec.ts | 79 ------------------------------ src/utils/linked-deps-hint.ts | 35 ------------- 5 files changed, 1 insertion(+), 119 deletions(-) delete mode 100644 src/utils/linked-deps-hint.spec.ts delete mode 100644 src/utils/linked-deps-hint.ts diff --git a/README.md b/README.md index 4bdb51c..2dd4190 100644 --- a/README.md +++ b/README.md @@ -653,7 +653,7 @@ A cold `ng build` re-bundles a changed linked library either way, since the adap So that the default is never a silent surprise, a watching build that finds a linked shared package while the option is off says so once at startup: ``` -INFO Detected npm-linked shared packages: @my-scope/my-lib. Set 'watchLinkedDeps' to true on this target to rebuild when they change. +INFO Detected npm-linked shared packages: @my-scope/my-lib. Set 'watchLinkedDeps' to true to rebuild when they change. ``` #### Requirements diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 0a7dea6..eab0c8a 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -50,7 +50,6 @@ 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 { federationSourceFiles } from "./../../utils/federation-source-files.js"; -import { hintUnwatchedLinkedDeps } from "./../../utils/linked-deps-hint.js"; import { watchpackWatch } from "./../../utils/watchpack-watch.js"; import { federationBuildNotifier } from "./federation-build-notifier.js"; import { @@ -314,7 +313,6 @@ export async function* runBuilder( // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the // syncNfFileWatcher calls below a no-op) so the linked lib's real source is watched. const linkedDirs = linkedSharedDirs(normalized.config, normalized.options); - if (watch) hintUnwatchedLinkedDeps(normalized.config, normalized.options); const plugins = [ { diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index dc2988b..2caf23f 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -31,7 +31,6 @@ import { import { createAngularBuildAdapter } from '../../tools/esbuild/angular-esbuild-adapter.js'; import { checkForInvalidImports } from '../../utils/check-for-invalid-imports.js'; import { federationSourceFiles } from '../../utils/federation-source-files.js'; -import { hintUnwatchedLinkedDeps } from '../../utils/linked-deps-hint.js'; import type { NfRemoteBuilderSchema, NfRemoteInternalOptions } from './schema.js'; import { resolveNgBuilderOptions } from './resolve-ng-options.js'; @@ -116,7 +115,6 @@ export async function* runRemoteBuilder( // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the // syncNfFileWatcher calls below a no-op) so the linked lib's real source is watched. const linkedDirs = linkedSharedDirs(normalized.config, normalized.options); - if (nfBuilderOptions.watch) hintUnwatchedLinkedDeps(normalized.config, normalized.options); const assetEntries = normalizeRemoteAssetEntries( nfBuilderOptions.assets, diff --git a/src/utils/linked-deps-hint.spec.ts b/src/utils/linked-deps-hint.spec.ts deleted file mode 100644 index 528ce60..0000000 --- a/src/utils/linked-deps-hint.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { logger } from '@softarc/native-federation/internal'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { hintUnwatchedLinkedDeps } from './linked-deps-hint.js'; - -type Args = Parameters; - -describe('hintUnwatchedLinkedDeps', () => { - let root: string; - let info: ReturnType; - - // A workspace whose node_modules entry for `key` is a symlink; `outside` decides - // whether its real path escapes node_modules, which is what core calls a linked - // checkout as opposed to a package manager's own symlink. - const workspace = (key: string, outside: boolean): void => { - const real = outside - ? path.join(root, 'checkouts', key) - : path.join(root, 'node_modules', '.store', key); - fs.mkdirSync(real, { recursive: true }); - fs.writeFileSync(path.join(real, 'package.json'), JSON.stringify({ name: key, version: '1.0.0' })); - - const link = path.join(root, 'node_modules', key); - fs.mkdirSync(path.dirname(link), { recursive: true }); - fs.symlinkSync(real, link, 'dir'); - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'host' })); - }; - - const call = (key: string, watchLinkedDeps: boolean): void => - hintUnwatchedLinkedDeps({ shared: { [key]: {} } } as unknown as Args[0], { - workspaceRoot: root, - packageJson: path.join(root, 'package.json'), - watchLinkedDeps, - } as unknown as Args[1]); - - beforeEach(() => { - root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nf-hint-'))); - info = vi.spyOn(logger, 'info').mockImplementation(() => undefined); - }); - - afterEach(() => { - info.mockRestore(); - fs.rmSync(root, { recursive: true, force: true }); - }); - - it('names the linked package when the option is off', () => { - workspace('my-lib', true); - call('my-lib', false); - - expect(info).toHaveBeenCalledTimes(1); - const message = String(info.mock.calls[0][0]); - expect(message).toContain('my-lib'); - expect(message).toContain('watchLinkedDeps'); - }); - - it('stays quiet when the option is already on', () => { - workspace('my-lib', true); - call('my-lib', true); - - expect(info).not.toHaveBeenCalled(); - }); - - // The #130 shape: pnpm's default linker symlinks every dependency, so a hint keyed - // on "is a symlink" would fire for the whole dependency graph. - it('stays quiet for a symlink that resolves inside node_modules', () => { - workspace('my-lib', false); - call('my-lib', false); - - expect(info).not.toHaveBeenCalled(); - }); - - it('stays quiet when nothing is linked', () => { - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'host' })); - call('my-lib', false); - - expect(info).not.toHaveBeenCalled(); - }); -}); diff --git a/src/utils/linked-deps-hint.ts b/src/utils/linked-deps-hint.ts deleted file mode 100644 index a89439b..0000000 --- a/src/utils/linked-deps-hint.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { linkedSharedDirs, logger } from '@softarc/native-federation/internal'; - -type LinkedSharedDirsArgs = Parameters; - -function packageNameOf(dir: string): string { - try { - const name = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')).name; - return typeof name === 'string' ? name : dir; - } catch { - return dir; - } -} - -/** - * Without this the `watchLinkedDeps` default reads as a broken build: linking a library - * and getting no reload looks the same as the feature not working. Only worth saying - * while watching, and only when turning the option on would actually change something — - * so ask `linkedSharedDirs` with it forced on, since it returns `[]` when it is off. - */ -export function hintUnwatchedLinkedDeps( - config: LinkedSharedDirsArgs[0], - options: LinkedSharedDirsArgs[1] -): void { - if (options.watchLinkedDeps) return; - - const dirs = linkedSharedDirs(config, { ...options, watchLinkedDeps: true }); - if (dirs.length === 0) return; - - logger.info( - `Detected npm-linked shared packages: ${dirs.map(packageNameOf).join(', ')}. ` + - `Set 'watchLinkedDeps' to true on this target to rebuild when they change.` - ); -}