browser-esbuild builder implementation

Path A switched only the TopFans client from Webpack browser to browser-esbuild. SSR, prerender, and serverless.ts stayed on Webpack until Path B.

What shipped

Before (Webpack) After (Path A)
Client: @angular-devkit/build-angular:browser Client: @angular-devkit/build-angular:browser-esbuild
Output: dist/TopFans/browser/ Same folder. S3 / CloudFront path did not change.
SSR / SSG: ng run TopFans:server and :prerender Unchanged Webpack targets. Deploy still ran prerender-all-qa.
ng serve on Webpack Vite-backed serve. Restart required after the switch.

Commits: d51206db97 (builder + assets) and 268f237e1e (runtime require() / cycle). Path B later replaced this client builder with application.

1. Swap the client builder only

The schema is almost the Webpack one. main, outputPath: "dist/TopFans/browser", serviceWorker: true, and ngswConfigPath stay valid. vendorChunk is not — it was dropped from every client configuration so the CLI would not reject the file.

"builder": "@angular-devkit/build-angular:browser-esbuild",
"options": {
  "outputPath": "dist/TopFans/browser",
  "index": "src/index.html",
  "main": "src/main.ts",
  "polyfills": "src/polyfills.ts",
  "tsConfig": "tsconfig.app.json"
}

White-label generation still cloned the TopFans build target, so tenants inherited esbuild for the client without a generator rewrite. Karma and the leftover serverless Webpack target were left alone.

2. Sass paths and flag icons

esbuild’s Sass resolver does not treat the workspace root the same way Webpack did. Imports like src/assets/... failed until the preprocessor include list added "." (client, serverless, and server targets).

"stylePreprocessorOptions": {
  "includePaths": ["sass", "."]
}

Flag CSS was a second blocker. Webpack could emit lazy-flag-icons.css as a named style bundle. esbuild hashed the same SVGs from two CSS files into colliding media/ names. The lazy bundle was removed. CSS and flags are copied as assets; the loader now points at the unhashed file.

{
  "glob": "flag-icons.min.css",
  "input": "node_modules/flag-icon-css/css",
  "output": "/"
},
{
  "glob": "**/*",
  "input": "node_modules/flag-icon-css/flags",
  "output": "/flags"
}
export const LAZY_CSS_LOADER_CONSTANTS = {
  hrefs: {
    'lazy-owl-carousel': '/lazy-owl-carousel.css',
    'lazy-emoji-mart': '/lazy-emoji-mart.css',
    'lazy-flag-icons': '/flag-icons.min.css',
    'lazy-primeng-theme': '/lazy-primeng-theme.css'
  }
} as const;

LazyCssLoaderService reads those hrefs instead of concatenating /${bundle}.css.

3. Remove Node require() from the browser polyfill

esbuild does not polyfill Node require in the client the way Webpack did. This line compiled under Webpack and failed immediately on esbuild:

(window as any).global.util =
  (window as any).global.util || require('util').util;

It was unused. window.global and the debug process.env stub stayed.

4. Quill as ESM, not runtime require

text-editor.component.ts loaded Quill only after typeof window !== 'undefined' via require('quill'). That is a Webpack habit. esbuild leaves those calls as runtime require, which is not defined in the browser. Quill is a static ESM import; the default/CJS interop is normalized once.

import QuillDefault from 'quill';
import 'quill-emoji';

const Quill: any = (QuillDefault as any)?.default ?? QuillDefault;

if (isBrowser) {
  BlockEmbed = Quill.import('blots/block/embed');
  // custom blots register here
}

The browser guard remains around blot registration so SSR does not touch the DOM. The package load itself is no longer lazy.

5. Break the CommonService cycle

esbuild evaluates modules in a stricter order than Webpack. CommonService used a lazy require('../../../core/auth.service') to hide a cycle with AuthService. Leaf icon components also imported CommonService only for isBrowser. That pulled the cycle into component definition time and Angular failed to resolve the defs.

The service now injects AuthService normally. The icons ask the platform directly:

constructor(@Inject(PLATFORM_ID) private readonly platformId: object) {
  this.onResize();
}

onResize() {
  if (isPlatformBrowser(this.platformId)) {
    this.isMobile = window.innerWidth <= 992;
  }
}
const authService = this.injector.get(AuthService);

What did not change

Validation already done

Why Path B still had to happen

Path A made ng serve and the client compile faster. It did not unify output. QA still needed a Webpack server/prerender pass, and the two toolchains disagreed about CSS, chunks, and Node shims. The application builder (see application-builder-implementation.html) is the step that collapsed those targets.