Skip to content

v0.11.0: builder parity — close all parity:important issues - #189

Merged
lukekania merged 32 commits into
mainfrom
milestone/v0.11.0-builder-parity
Jul 15, 2026
Merged

v0.11.0: builder parity — close all parity:important issues#189
lukekania merged 32 commits into
mainfrom
milestone/v0.11.0-builder-parity

Conversation

@lukekania

Copy link
Copy Markdown
Owner

Summary

Closes out all nine parity:important issues: full dev-server option parity (SSL, custom headers, allowedHosts, HMR), bundler parity (externalDependencies, per-provider vendor tree-shake), i18n parity (localize subsets, per-locale ngsw.json), and the @if (expr; as alias) runtime alias binding. Version moves to 0.10.18.

Issues closed

Closes #142
Closes #143
Closes #144
Closes #145
Closes #146
Closes #147
Closes #148
Closes #166
Closes #171

Included PRs

PR Feature Issue
#176 template-compiler: bind @if (expr; as alias) value at runtime #166
#174 bundler: honor angular.json externalDependencies #146
#180 i18n: honor localize: [...] per-locale subset #147
#173 dev-server: allowedHosts (Codespaces / ngrok / *.localhost) #144
#179 bundler: per-provider tree-shake for vendor chunks #171
#182 ngsw: per-locale ngsw.json manifests under --localize #148
#183 dev-server: headers custom HTTP response headers #143
#184 dev-server: ssl/sslKey/sslCert HTTPS dev #142
#185 dev-server: HMR config + global CSS hot-swap #145
#186 dev-server: /@ng/component endpoint + HMR runtime bus #145
#187 HMR: component template & style hot module replacement #145
#188 builder: accept and forward the dev-server hmr option #145

Verification

Each feature was verified against test-ng-project through the Architect builder (ng build / ng serve with @ngc-rs/builder), most recently the full HMR path: hmr: true passes schema validation, CSS edits hot-swap via css-update SSE events, template edits re-render via angular:component-update, and ng serve --no-hmr overrides angular.json in both directions.

lukekania and others added 29 commits May 18, 2026 10:45
…#166)

`#165` stopped the build from breaking on the `; as alias` syntax by stripping
the alias clause at codegen time. The alias itself was still unbound:
references like `{{ alias.name }}` compiled to `ctx.alias` on the parent
component, which is `undefined` for any component that does not happen to
carry a field of that name.

This change makes the alias actually flow through Angular's `ɵɵconditional`
contract:

- The truthy expression value is passed as `ɵɵconditional`'s second
  argument, so the matching template's embedded view receives it as its
  `_ctx` parameter.
- The matching branch's child template function uses the alias name as its
  `_ctx` parameter, so body references like `{{ it.name }}` resolve to the
  local directly.
- `@else if (expr; as <alias>)` works the same way, branch-for-branch — the
  alias-value chain mirrors the slot-selection ternary so each branch's
  truthy value lights up only when that branch matches.
- Nested templates inside an aliased `@if` (e.g. a `@switch` case that
  reads `s.error`) bind the alias via a single `ɵɵnextContext()` walk back
  to the aliased ancestor's embedded view. Both the update prelude and
  listener closures emit this binding.
- Pipes inside an `@if` condition (`state$ | async; as s`) now route through
  `compile_binding_expr` so `ɵɵpipe(...)` registers at the correct slot
  scope and the resulting `ɵɵpipeBind1(...)` form is reused as both
  `ɵɵconditional` arguments. Previously a piped condition compiled to a
  bitwise-OR expression.

Codegen-shape regression tests pin the emission for the four shapes the
fixture exercises (basic alias, no-alias parity, `@else if` per-branch alias,
nested-scope alias). A new integration test under
`crates/template-compiler/tests/` walks `compile_component` end-to-end for
the same shapes.
feat(template-compiler): bind `@if (expr; as alias)` value at runtime
Adds support for `externalDependencies` in `angular.json` so projects
loading specific npm packages from a CDN (or expecting them as
`import` map entries) can exclude those packages from the bundle —
matching `@angular/build:application`.

- project-resolver: parse `externalDependencies` (base + per-config
  override) into `ResolvedAngularProject.external_dependencies`.
- npm-resolver: new `resolve_npm_dependencies_with_externals` skips
  externalised specifiers in phase 1 and the transitive BFS, so a
  package and all its modules stay out of `npm_resolution`.
- bundler: `BundleInput.external_specifiers` vetoes `is_local` —
  an import whose specifier matches an external entry (exact or
  `<name>/...` subpath) is emitted verbatim and never rewritten to a
  `__ns_*` namespace reference. Lazy-chunk routing keeps externals
  as bare specifiers instead of folding them through `./main.js`.
- cli: thread externals through every npm-resolver call site; strip
  externals from `bundled_specifiers` defensively before bundling.
- builder: drop the stale "currently ignored" warning.

Verified against `test-ng-project`: declaring `externalDependencies:
["jquery"]` and importing `$ from 'jquery'` (and the subpath
`jquery/dist/jquery.slim`) emits the imports verbatim in
`main.js`, jquery is not installed in the fixture so unresolved
externals are silently skipped by the resolver as intended.

Bumps workspace to 0.10.10.
feat(bundler): honor angular.json `externalDependencies` (#146)
Switch `--localize` from a bool flag to an optional comma-separated
list so CI builds can emit just the locales they need.

  ngc-rs build --localize           # all i18n.locales (unchanged)
  ngc-rs build --localize=en-US,de  # only those two subdirs

The architect builder now serializes `localize: ['en', 'de']` as
`--localize=en,de` instead of dropping the array and warning. An empty
array still falls back to "all locales" to match `@angular/build`.

`fan_out_locales` validates each subset entry against the source locale
and `i18n.locales` keys; an unknown locale fails the build with a
clear error rather than silently producing an empty `dist/`.
feat(i18n): honor `localize: [...]` per-locale subset
…lhost) (#144)

Add the `allowedHosts` knob the `@angular/build:dev-server` builder
exposes so projects fronted by a tunneling proxy (ngrok, Cloudflare
Tunnel, GitHub Codespaces) or running under a non-default local
hostname (`*.localhost`, `app.local`) don't get rejected by the dev
server's `Host:` header check.

* `packages/builder/schemas/dev-server.json`: add `allowedHosts: array`.
* `packages/builder/src/serve/options.ts`: forward the list as
  `--allowed-hosts host1,host2`, normalizing empty/whitespace entries
  and case-insensitively deduping.
* `crates/cli`: new `--allowed-hosts` flag on `ngc-rs serve` (value
  delimiter `,`) wired through `serve_cmd::run` to the dev server.
* `crates/dev-server`: new `AllowedHosts` resolver + filter in
  `handle_request`. Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`)
  are always allowed. The literal `"all"` disables the check entirely;
  `"auto"` (or an empty list, the default) additionally accepts the
  bound host. Anything else is an exact, case-insensitive hostname
  match with the `Host:`-header port stripped before comparison.
  Mismatches respond with a 403 whose body names the offending host
  and points at both the angular.json option and the CLI flag.

Bumps workspace version to 0.10.12.
feat(dev-server): support `allowedHosts` (Codespaces / ngrok / *.localhost) (#144)
Generalize cross-chunk used-names collection so every chunk gets its own
externally-used set, not just main. Vendor chunks holding `@angular/core`
or `rxjs` previously pinned every export the package declared because
`externally_used = None` made the shaker fall back to entry-walk
reachability — on `index.mjs`-style packages that reaches almost
everything.

- `shake::collect_cross_chunk_used_names_per_provider` returns
  `Vec<HashSet<String>>` indexed by chunk index; for each chunk i it
  collects the names other chunks import from any module in i.
- `bundle()` builds a bare-specifier → canonical-path map from the
  existing namespace tables so bare imports (`'@angular/core'`) attribute
  to the owning vendor chunk, then feeds `externally_used_per_chunk[idx]`
  into `analyze_unused_exports` for every chunk — the `is_main` gate is
  dropped.
- `npm_wrap::wrap_npm_module` now accepts `unused_exports` and drops both
  the unused `export const X = ...` declarations and the matching
  `__exports.X = ns.X` re-export bridges, so shake decisions reach the
  emitted vendor chunk code.

Bumps version to 0.10.13.
feat(bundler): per-provider tree-shake for vendor chunks
Previously a localized build with serviceWorker enabled skipped ngsw.json
generation entirely, forcing apps to choose between i18n and PWA caching.

Move the service-worker step to run after locale fan-out and, when
--localize is in use, emit one ngsw.json per <out_dir>/<locale>/ deploy
root. Asset hashes are computed from each locale's own tree, so a
translated bundle hashes differently per locale — correct cache
invalidation. The non-localized path is unchanged.

Bump version to 0.10.14.
feat(ngsw): per-locale ngsw.json when --localize is set (#148)
The dev-server `headers` option in angular.json was silently dropped, so
apps relying on production-like security headers in dev (CSP, COOP) or
testing CORS scenarios had no way to configure them.

- `dev-server.json`: add `headers` (object of name to string value).
- `serve/options.ts`: serialize the map into a `--headers` JSON arg,
  trimming names and dropping empty-name / non-string entries.
- `serve_cmd.rs` / `main.rs`: parse the `--headers` JSON object and
  thread the name/value pairs into `DevServerConfig`.
- `dev-server`: new `CustomHeaders` type applies the configured headers
  to every served response — static assets, the SPA-fallback
  index.html, and the SSE live-reload stream. Headers the server sets
  itself (`Content-Type`, `Cache-Control`, and the SSE-specific
  `Connection` / `Access-Control-Allow-Origin`) are never overridden.

Headers are applied only in the Rust dev server, so proxy-forwarded
responses keep their upstream headers untouched.

Bump version to 0.10.15.
feat(dev-server): support `headers` custom HTTP response headers (#143)
The dev-server builder hard-failed whenever ssl/sslKey/sslCert was set, so
projects needing HTTPS in dev (OAuth callbacks, secure-cookie testing,
mixed-content debugging, service-worker registration) had to stand up a
separate TLS-terminating proxy. ngc-rs now serves HTTPS directly.

- dev-server: new TlsConfig (from_pem for explicit material, self_signed via
  rcgen for the auto-generated case, covering the bind host plus the loopback
  names). DevServerConfig::with_tls wires the PEM into tiny_http's ssl-rustls
  backend; the SSE live-reload stream rides the same TLS connection.
  DevServer::scheme() reports https/http. The private key is redacted from
  Debug output.
- cli: --ssl/--ssl-key/--ssl-cert on serve. resolve_tls enforces both-or-
  neither cert paths and that key/cert require --ssl; the printed URL uses the
  right scheme.
- builder: options.ts forwards the flags, resolves cert paths against the
  workspace root, emits an https:// URL, and rejects ssl + proxyConfig (the
  proxy is the browser-facing endpoint; Node-side TLS termination is out of
  scope). Schema descriptions updated; README serve section corrected.

ssl: true without explicit key/cert mints a throwaway self-signed certificate,
matching @angular/build:dev-server; browsers show the usual untrusted-cert
warning.

Bump version to 0.10.16.
feat(dev-server): support `ssl`, `sslKey`, `sslCert` for HTTPS dev (#142)
First slice of Hot Module Replacement: read the `hmr` flag and hot-swap
global stylesheets in place without a full page reload.

- project-resolver: parse `architect.serve.options.hmr` (base + active
  serve configuration override) into `ResolvedAngularProject.hmr`;
  defaults to false.
- cli: add `--hmr` / `--no-hmr` to `serve` (CLI wins over angular.json,
  else inherit, else false). When HMR is on and a rebuild touches only
  global stylesheet entries, emit a CSS-update instead of a reload.
- dev-server: new `DevServerEvent::CssUpdate { timestamp }` →
  `event: css-update` SSE frame; the injected client swaps the
  `styles.css` <link> with a cache-busted href, preserving page state.

Component template/style HMR and the `/@ng/component` update endpoint
land in follow-up slices on the milestone branch.
Runtime scaffolding for component-level HMR, ahead of the compiler
codegen that populates it.

- dev-server: new `DevServerEvent::ComponentUpdate { id, timestamp }` →
  `event: angular:component-update` SSE frame; a shared `ComponentUpdates`
  registry (id → update-module source) on `DevServerConfig`, served at
  `GET /@ng/component?c=<id>` (text/javascript, empty 200 for unknown ids,
  400 when `c` is absent) — mirrors @angular/build's component middleware.
- client: the injected script publishes a `window.__ngcHmr` bus and
  dispatches `angular:component-update` to registered handlers; new
  `HMR_RUNTIME_PRELUDE` binds `import.meta.hot` to that bus.
- serve: share the registry with the dev server and prepend the runtime
  prelude to `main.js` on each HMR build so per-component initializers can
  resolve `import.meta.hot`.

The registry stays empty and component edits still trigger a full reload
until the template-compiler emits update modules (next slice).
Lights up Angular-parity template/style HMR end to end, building on the
config plumbing, dev-server endpoint, and runtime bus from the previous
slices.

template-compiler:
- New `hmr` module: `encode_uri_component` (JS semantics), per-component
  id `encodeURIComponent("relpath@Class")`, the initializer IIFE, and the
  update-module codegen. The update module reuses the existing Ivy def
  verbatim via the linker's var-alias technique (`var XX = i0.XX`), with
  `@angular/core` arriving as the `namespaces` arg and template deps as
  positional params; it reassigns only `ɵcmp` (template/style-only path).
- `CompileOptions.hmr` and `CompiledFile.hmr` carry the artifacts;
  `rewrite_source` adds `import * as i0` so the appended initializer can
  reach `ɵɵreplaceMetadata`.

cli:
- Thread `hmr` through `run_build_with_options`; aggregate per-component
  update modules and resource→component maps into `BuildResult`; cache the
  artifacts in the incremental `CachedModule`.
- serve: classify each rebuild — global stylesheet → css-update, external
  component template/style → component-update(s), `.ts`/inline/unknown →
  full reload — and inject the `import.meta.hot` prelude into every chunk
  (eager `main.js` and lazy `chunk-*.js`).

Verified against an Angular 21 app: editing a component `.html`/`.scss`
swaps it in place via the `/@ng/component` update module and
`ɵɵreplaceMetadata`; global styles swap the `<link>`; `.ts` reloads.
feat(dev-server): hmr config + global CSS hot-swap (#145)
feat(dev-server): /@ng/component endpoint + HMR runtime bus (#145)
feat(hmr): component template & style hot module replacement (#145)
The Rust side of HMR landed in PRs #185-#187, but the Architect builder
still rejected `hmr` at schema validation (additionalProperties: false)
and never forwarded a flag, so `ng serve` users could not reach it.

- schemas/dev-server.json: declare `hmr` (boolean, no default)
- serve/options.ts: tri-state forwarding — true → --hmr, false →
  --no-hmr, unset → nothing (binary inherits architect.serve.options.hmr
  from angular.json)
feat(builder): accept and forward the dev-server hmr option (#145)
…d template listeners

Tree-shaking (issue #171): barrel-export npm packages are now shaken to the
used exports only, matching @angular/build. Two gaps are fixed in the chunk
shake:

- Bare package specifiers (`import { debounce } from 'lodash-es'`) now resolve
  through node_modules, so reachability can follow the barrel re-export edge to
  the one used leaf instead of pinning the whole package.
- The owning package's `sideEffects` field is honoured. A file in a
  `sideEffects: false` package is treated as free of module-level effects and
  may be dropped when unreached, even with top-level statements. Without this,
  lodash-es's `lodash.default.js` (hundreds of top-level `_.x = ...` lines)
  was pinned as side-effectful and dragged the entire package into the chunk.

The shake analysis was reworked from "imported-by-anyone" to a reachability
fixpoint over (module, export-name) pairs, with whole-module dead-code
elimination for unreachable npm modules. Result on the test app's
vendor-treeshake route: the lodash chunk drops from 133 KB to 8.3 KB
(@angular/build reference: 6.7 KB), carrying only debounce and its transitive
deps.

Template listeners: the compiler now matches @angular/build for two constructs
that previously produced runtime errors:

- `$any()` casts in listener expressions are stripped at compile time instead
  of emitting a `ctx.$any(...)` call that throws `TypeError`.
- Template reference variables read inside a root-level listener
  (`<input #box (input)="f(box.value)">`) resolve via `restoreView` +
  `reference()` instead of throwing `ReferenceError`.

Adds a `read_side_effects` reader in npm-resolver, an end-to-end
barrel-treeshake integration test mirroring the lodash-es shape (bare specifier
+ `sideEffects: false` aggregator), and unit coverage for the new paths.

Bumps workspace version to 0.10.19.
@lukekania

Copy link
Copy Markdown
Owner Author

Parity batch: tree-shaking + template listeners (4859d0e)

  • feat(bundler): per-provider tree-shake for vendor chunks #171 — barrel-package vendor tree-shake now matches @angular/build (lodash chunk 133 KB → 8.3 KB vs 6.7 KB reference). Bare-specifier resolution + sideEffects honoured in the chunk shake; reachability-based whole-module DCE.
  • $any() in listeners — stripped at compile time instead of emitting ctx.$any(...) (was TypeError at runtime).
  • Template refs in listeners<input #box (input)="f(box.value)"> resolves via restoreView + reference() (was ReferenceError).

Version → 0.10.19. Tests: bundler 87 + integration, npm-resolver 63, template-compiler 266 — all green.

Apply rustfmt across the workspace to satisfy `cargo fmt --all -- --check`.
Reflows the new tree-shake/side-effects code plus pre-existing formatting
drift on the milestone branch (serve_cmd, hmr, if_alias tests) that was
failing CI. No behavioural changes.
Replace a nested `match ... { Some(d) => ..., None => return None }` with the
`?` operator. Pre-existing lint that only surfaced now that `cargo fmt --check`
no longer fails the CI lint job first.
@lukekania lukekania added this to the v0.11.0 — Builder parity milestone Jul 15, 2026
Finalize the v0.11.0 builder-parity milestone. This release closes the
parity:important set: dev-server option parity (SSL, headers, allowedHosts,
HMR), bundler parity (externalDependencies, barrel-package vendor
tree-shaking), i18n parity (localize subsets, per-locale ngsw.json),
`@if (expr; as alias)` runtime binding, and template-listener fixes
(`$any()` strip, template refs).
@lukekania
lukekania merged commit ecf4076 into main Jul 15, 2026
7 checks passed
@lukekania
lukekania deleted the milestone/v0.11.0-builder-parity branch July 15, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment