Skip to content

fix(builder): keep federation externals out of the dev server's stylesheet build - #169

Open
JasinRefiku wants to merge 3 commits into
native-federation:mainfrom
JasinRefiku:fix/dev-server-stylesheet-externals
Open

JasinRefiku wants to merge 3 commits into
native-federation:mainfrom
JasinRefiku:fix/dev-server-stylesheet-externals

Conversation

@JasinRefiku

@JasinRefiku JasinRefiku commented Oct 5, 2026 •

Copy link
Copy Markdown

Fixes #167.

Problem

Under the dev server, createInternalAngularBuilder added every federation external to Angular's externalDependencies so that serveWithVite routes them to Vite's optimizeDeps.exclude. But @angular/build (22.2.1) passes that same normalized option to its stylesheet bundles too:

  • builders/application/setup-bundling.js, createComponentStyleBundler: externalDependencies goes to ComponentStylesheetBundler.
  • tools/esbuild/global-styles.js, createGlobalStylesBundleOptions: the same for global styles.
  • tools/esbuild/stylesheets/bundle-options.js: external: options.externalDependencies.

esbuild treats an external package name as covering its subpaths. So when the Sass importer in tools/esbuild/stylesheets/sass-language.js calls build.resolve('@taiga-ui/core/styles/mixins/mixins', { kind: 'import-rule' }), it gets an external result with no path. Its <pkg>/package.json fallback is external too, and Sass reports "Can't find stylesheet to import". ng build isn't affected, because there the externals only reach the browser bundle, through the externals plugin.

Where the externals actually need to go

I traced externalDependencies through @angular/build 22.2.1:

  • Vite. builders/dev-server/vite/index.js passes browserOptions to the builder action and, after each result, calls updateExternalMetadata(result, externalMetadata, browserOptions.externalDependencies). It reads the list back from the object it passed in. tools/vite/utils.js adds that list to explicitBrowser and explicitServer, and dev-server/vite/server.js turns those into optimizeDeps.exclude, ssr.external/ssr.optimizeDeps.exclude, the memory plugin's externals and the /@id/ prefix removal. The full list matters here, not just what the app imports directly. A shared package imported only from inside a non-shared package that Vite pre-bundles must still be excluded, or the pre-bundle inlines a second copy.
  • Browser main bundle. The builder's externals plugin sets build.initialOptions.external on every non-node code bundle unless activateSsr is set, and that replaces whatever came from externalDependencies. esbuild's initialOptions is the options object itself, so bundler-context.js also reports these externals as externalConfiguration, and execute-build.js classifies matching imports as explicit externals.
  • Server bundles, polyfills and the ngServerMode banner. Code plugins only run in the browser main, server main and SSR entry bundles (application-code-bundle.js). The browser and server polyfill bundles take external from externalDependencies only. Angular also adds globalThis['ngServerMode'] = true to the SSR entry and server polyfills only when externalDependencies (or the polyfills' external) is non-empty (fix(@angular/build): handle external @angular/ packages during SSR angular/angular-cli#29094). The builder's plugin skips platform: 'node'.

Change

  • The options object serveWithVite passed in still gets the full list (target externals plus federation externals), so Vite behaves exactly as before.
  • The build gets a Proxy over those same options whose externalDependencies is the target's own list, but only when all of these hold:
    • the externals plugin covers the browser bundle (new externalsPlugin option; the builder derives it and the plugin's condition from one variable),
    • there is no server bundle (options.server unset; Angular only builds server bundles from a server entry), and
    • no polyfill is a local file (Angular's isLocalFile check; see below).
  • Writes still reach the served options. normalizeOptions in builders/application/options.js sets ssr = false and prerender = !!server when outputMode is set, and serveWithVite picks its SSR middleware from ssr afterwards. A shallow copy would swallow those corrections.
  • Otherwise the build keeps the federation externals as before, so SSR dev serving, federated SSR builds and apps with a local polyfills file are unchanged.

What changes for a CSR dev server:

  • Stylesheets no longer see the federation externals (the fix).
  • The browser main bundle is unchanged. The externals plugin overrides external anyway.
  • Vite's lists are unchanged, since they come from the served options.
  • Browser polyfills (only package names, no local file) now use only the target's externals. With prebundle on (the default) the output is the same: every package is external there, and the merged externalConfiguration from the main bundle still classifies shared imports as explicit. With prebundle: false, a shared package listed in polyfills is bundled, as ng build already does. zone.js and @angular/localize are on the skip list, so this rarely applies.

Local polyfill files

Angular turns off externalPackages for the polyfills bundle as soon as a polyfill is a local file (getEsBuildCommonPolyfillsOptions), and code plugins don't reach that bundle. So externalDependencies is the only thing keeping a shared package imported from polyfills.ts external, and the build keeps the federation externals in that case. Those apps still hit #167 under the dev server.

ng build already inlines such imports: for CSR the federation externals only come from the externals plugin. Reverting 2575c0a would make the dev server match production and fix #167 for these apps too. It's a separate commit so it can be dropped if parity is preferred.

Alternatives considered

  • Strip the externals from the build in every case. This breaks SSR. Server bundles and polyfills would lose their explicit externals, and Angular would drop the ngServerMode banner while Vite still loads the shared Angular packages natively through ssr.external. With ssr and no dev, the browser bundle would lose its externals altogether, because the plugin is off.
  • Add the externals to the code bundles through our own code plugin and take them out of externalDependencies everywhere. Code plugins don't reach the polyfill bundles or the banner logic, so this has the same SSR gaps.
  • A stylesheet-only hook. ApplicationBuilderExtensions only has codePlugins and indexHtmlTransformer. serveWithVite only takes middleware and buildPlugins. Stylesheet bundles are created internally (createComponentStyleBundler, createGlobalStylesBundleOptions) from the normalized options, so there's nothing to hook into without deep imports.
  • Make the externals match exact specifiers only. esbuild's external has no exact-only form for package paths: foo always implies foo/*, and * patterns are prefix/suffix matches. Deep imports would also behave differently.
  • Add node_modules to stylePreprocessorOptions.includePaths. That's the workaround from the issue. It only helps Sass with hoisted packages, ignores package exports/style conditions, and doesn't fix CSS or Less @import.

This only changes which options we pass to buildApplicationInternal and serveWithVite. There are no new imports from @angular/build and nothing written to node_modules. The only copied code is Angular's four-line isLocalFile, tagged // upstream:.

Not covered

  • SSR dev serving (a server entry) keeps the federation externals in the build, so a stylesheet @use of a shared package still fails there. Fixing it needs either an Angular seam for stylesheet externals or our own handling of the server polyfills and ngServerMode banner. The includePaths workaround from the issue still works.
  • CSR apps with a local polyfills file keep the federation externals in the build (see "Local polyfill files" above).
  • Federated SSR production builds (ssr: true, dev: false) set ngBuilderOptions.externalDependencies = externals in builder.ts and have the same stylesheet issue. Left out to keep this PR focused.

Testing

  • internal-angular-builder.spec.ts:
    • With the plugin and no server, the build keeps the target's externals (or leaves them unset) while the served options get the full list.
    • The builder define and instrumentForCoverage still reach both objects.
    • With a server entry, or with the plugin off, the build keeps the federation externals.
    • Angular's ssr/prerender corrections written to the build options reach the served options.
    • With a local polyfill file (.ts, .mjs, ./-relative), the build keeps the federation externals. zone.js and other package polyfills don't count as local.
    • Integration: Angular's real ComponentStylesheetBundler (from @angular/build/private) compiles @use 'shared-ui/styles/mixins' from a fake package in a temp workspace, using the externalDependencies the build now gets. As a control, the full list reproduces "Can't find stylesheet to import". It takes about 100 ms. Each bundle uses its own workspace root because Angular caches Sass package resolutions per working directory for the whole process.
  • The three tests that exercise the change fail on main. The guard tests (server entry, plugin off, local polyfill, option corrections) pass on both. The option-corrections test failed on 164f55a, before the Proxy.
  • pnpm lint (no errors; 34 existing warnings, the same as on main), pnpm typecheck, pnpm build and knip pass.
  • pnpm test: 402 tests pass. On macOS with the default TMPDIR, sourcemap-ignorelist-plugin.spec.ts › "marks node_modules sources of a bundle emitted into the federation cache" fails on main too. It compares paths under /var/folders/... with esbuild's /private/var/... realpaths. With a non-symlinked TMPDIR the whole suite passes.
  • End to end (run on 164f55a, before the review follow-ups), in a CSR Nx workspace that shares @taiga-ui/core as a singleton (Angular 22.2.0, adapter 22.2.1 with this change applied to the installed builder, the includePaths workaround removed):
    • Without the change, nx serve fails with "Can't find stylesheet to import" on the @taiga-ui/core/styles/... @uses.
    • With it, the bundle generation completes and the app bootstraps in the browser.
    • remoteEntry.json still lists the shared packages, and Vite's deps cache holds none of them.

…sheet build

The dev server added every federation external to Angular's externalDependencies so that
serveWithVite routes them to Vite's optimizeDeps.exclude. @angular/build also passes that
option to its stylesheet bundler, and esbuild treats an external package name as covering
its subpaths, so a Sass `@use '@taiga-ui/core/styles/...'` of a shared package failed with
"Can't find stylesheet to import". `ng build` was unaffected.

serveWithVite reads externalDependencies back from the options object it passed in, so the
full list stays there for Vite. The build itself now gets the target's own list when the
`externals` plugin already marks the browser bundle's externals and there is no server
bundle. With a server bundle, or with the plugin off for a federated SSR build, the build
keeps them: server bundles and their polyfills don't run the plugin, and Angular only adds
the ngServerMode banner for externals when externalDependencies is set.

Fixes native-federation#167

@Aukevanoost Aukevanoost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracking this down, the stylesheet bundler test is a nice touch! A few things I noticed below.

// ngServerMode banner Angular adds for externals only get them from here.
const buildOptions =
opts?.externalsPlugin && !options.server
? { ...options, externalDependencies: targetExternals }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since normalizeOptions mutates the options it receives (options.ssr = false / options.prerender = !!options.server when outputMode is set without server), those corrections now land on the copy and serveWithVite keeps reading the original browserOptions.ssr. So e.g. outputMode: 'static', ssr: true without server would pick InternalSsrMiddleware (dev-server/vite/index.js) while no server bundle is built. Could we carry ssr back, or avoid the copy for that case?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 83cac6c. The build now gets a Proxy over the served options that only overrides the externalDependencies read, so normalizeOptions' ssr/prerender corrections land on the object serveWithVite reads. Added a spec for outputMode + ssr without server.

const buildOptions =
opts?.externalsPlugin && !options.server
? { ...options, externalDependencies: targetExternals }
: options;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more on the copy: the browser polyfills bundle doesn't get the codePlugins, and Angular disables externalPackages for it as soon as a polyfill is a local file (getEsBuildCommonPolyfillsOptions). So externalDependencies was the only thing keeping shared packages external there; with this change a polyfills.ts importing a shared package (e.g. @angular/localize/init under shareAll) would inline its own copy. Maybe keep the federation externals for the polyfills somehow, or limit the change to the stylesheet side?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 2575c0a, as a separate commit: when any polyfill is a local file (same check as Angular's isLocalFile), the build keeps the federation externals, so a polyfills.ts behaves as before.

One pushback, though: ng build already inlines shared packages imported from a local polyfills file. For CSR the builder only passes the federation externals through the externals plugin (externalDependencies is only set in the activateSsr branch of builder.ts), and the plugin doesn't reach the polyfills bundle. So without 2575c0a the dev server matches production and apps with a polyfills.ts get the #167 fix too. With it, those apps still hit #167 under ng serve. (@angular/localize/init is in NG_SKIP_LIST, so it isn't shared under shareAll.)

I'm fine either way. If you'd rather have dev/prod parity, I'll revert 2575c0a.

Comment thread src/builders/build/builder.ts Outdated
createInternalAngularBuilder(externals, {
instrumentForCoverage: nfBuilderOptions.instrumentForCoverage,
define: nfBuilderOptions.define,
externalsPlugin: !activateSsr,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small note: with activateSsr the full list still goes into ngBuilderOptions.externalDependencies (line 319), so federated SSR prod builds (and the server case in the internal builder) still hit #167. Fine to leave for a follow-up, but might be worth mentioning in the PR. Also, this flag duplicates the condition in the externals plugin (line 301); deriving both from one variable would keep them in sync.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both SSR cases (SSR dev serving with a server entry, and federated SSR production builds) are listed under "Not covered" in the description, left for a follow-up. The duplicated condition is fixed in 83cac6c: useExternalsPlugin now drives both the plugin and the externalsPlugin option.

@Aukevanoost Aukevanoost added the question Further information is requested label Oct 17, 2026
normalizeOptions writes its `ssr` and `prerender` corrections onto the options it gets (when
`outputMode` is set without `server`), and serveWithVite picks its SSR middleware from the
options it passed in. With the shallow copy those writes landed on the copy, so `outputMode`
with `ssr` and no `server` chose InternalSsrMiddleware without a server bundle. The build now
gets a proxy that only overrides the externalDependencies read, so the writes reach the
original options.

Also derive the `externals` plugin condition and the internal builder's `externalsPlugin`
option from one variable.
…ill is a local file

Angular turns off `externalPackages` for the polyfills bundle as soon as a polyfill is a local
file, and code plugins don't reach that bundle, so externalDependencies was all that kept a
shared package imported from `polyfills.ts` external under the dev server. Keep the federation
externals in the build in that case (using a copy of Angular's isLocalFile), so those polyfills
behave as before. Such apps still hit native-federation#167 under the dev server.

`ng build` already inlines shared packages imported from a local polyfills file, since its
externals only come from the `externals` plugin. Reverting this commit makes the dev server
match it and fixes native-federation#167 for those apps too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

question Further information is requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dev server: shared packages added to externalDependencies break Sass @use of those packages ("Can't find stylesheet to import")

2 participants