Command
serve
Is this a regression?
The previous version in which this bug was not present was
21.2.12
Description
For applications using zone.js, the CLI downlevels async/await so that zone.js can follow the continuation — it can only patch ZoneAwarePromise.then, never a native await.
Under ng serve on v22 this downleveling is no longer applied to Vite-prebundled dependencies. FetchBackend — the default HTTP backend since v22 — does:
const fetchPromise = this.ngZone.runOutsideAngular(() => this.fetchImpl(...));
response = await fetchPromise; // native await → the zone is lost here
so the remainder of doRequest, including observer.next(response), resumes in the root zone. Consequence: every HttpClient response callback in the application runs outside the Angular zone, NgZone.onMicrotaskEmpty never fires, ApplicationRef.tick() never runs, and any component mutating plain properties in an HTTP subscribe does not repaint until an unrelated DOM event happens to trigger change detection.
The production build is not affected — it downlevels correctly — so this only manifests in development, which makes it hard to attribute.
Cause
getDepOptimizationConfig no longer applies getFeatureSupport(). In 21.2.x (packages/angular/build/src/tools/vite/utils.ts):
esbuildOptions: {
target,
supported: getFeatureSupport(target, zoneless), // → { 'async-await': false }
plugins, loader, define,
}
On main, after a583431 "refactor(@angular/build): migrate to optimizeDeps.rolldownOptions in Vite config":
rolldownOptions: {
transform: { define },
moduleTypes: loader,
resolve: { extensions: ['.mjs', '.js', '.cjs'] },
plugins: [ /* … */ ],
}
Neither target nor supported is passed. The same commit removed the zoneless argument from setupServer(...), so the information is no longer available at that point.
The compensating change added in that commit, in packages/angular/build/src/builders/dev-server/vite/index.ts:
if (!isZonelessApp(polyfills)) {
// Rolldown doesn't have an option to support Zone.js/async-await, so we need to support es2016.
target.push('es2016');
}
does not appear to have the intended effect: that target only reaches Vite's build.target, which is not what the dependency optimizer uses when prebundling. getFeatureSupport is now referenced only from application-code-bundle.ts, i.e. application code — which is consistent with what I observe: app sources are still downleveled correctly, dependencies are not.
Possible fix
The comment above may be outdated: rolldown 1.2.0 exposes TransformOptions.target (oxc lowering), and oxc lists async functions among the ES2017 features it lowers. Since rolldownOptions.transform is already populated, passing the target through may be sufficient:
rolldownOptions: {
transform: { target, define },
// …
}
with target carrying the es2016 entry serveWithVite already computes for zone.js applications.
Workaround
Re-enter the zone once in the outermost HTTP interceptor, rather than changing components:
export const angularZoneInterceptor: HttpInterceptorFn = (req, next) => {
const zone = inject(NgZone);
return new Observable<HttpEvent<unknown>>((observer) => next(req).subscribe({
next: (event) => zone.run(() => observer.next(event)),
error: (err) => zone.run(() => observer.error(err)),
complete: () => zone.run(() => observer.complete()),
}));
};
registered first in withInterceptors([...]), since the outermost interceptor is the last one to touch the response.
Minimal Reproduction
ng new repro --no-standalone=false
cd repro
app.config.ts — zone.js change detection plus HttpClient (zone.js is in polyfills by default):
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection(), provideHttpClient()],
};
app.component.ts:
@Component({
selector: 'app-root',
template: `{{ state }}`,
changeDetection: ChangeDetectionStrategy.Eager,
})
export class AppComponent {
state = 'waiting';
constructor(http: HttpClient) {
http.get('/favicon.ico', { responseType: 'text' }).subscribe(() => {
console.log('in Angular zone:', NgZone.isInAngularZone());
this.state = 'updated';
});
}
}
ng serve → console logs in Angular zone: false, and the page keeps showing waiting until a DOM event triggers change detection (clicking, or moving the mouse over an element with a listener).
ng build --configuration production and serving dist/ → logs in Angular zone: true and renders updated immediately.
Adding withXhr() also makes it work under ng serve, because HttpXhrBackend contains no await — which isolates async/await downleveling as the variable.
Evidence
Prebundled dependency keeps native async/await:
$ grep -o 'async doRequest' .angular/cache/22.1.3/app/vite/deps/http-*.js
async doRequest
$ grep -o 'await fetchPromise' .angular/cache/22.1.3/app/vite/deps/http-*.js
await fetchPromise
The same code in the production bundle is downleveled, therefore zone-safe:
doRequest(n, r, o) { return __async(this, null, function* () { /* … */ s = yield y; /* … */ }) }
Zone probe, a single HTTP call issued inside ngZone.run() under ng serve:
| backend |
zone at call |
zone in next |
FetchBackend (default) |
angular |
<root> |
HttpXhrBackend (withXhr()) |
angular |
angular |
Exception or Error
No exception is thrown. The application silently stops rendering state that was
updated inside an HTTP response callback.
Your Environment
Angular CLI : 22.1.3
Angular : 22.1.0
Node.js : 24.19.0
Package Manager : npm 11.17.0
Operating System : darwin arm64
Package Installed Version
@angular-devkit/architect 0.2201.3
@angular-devkit/build-angular 22.1.3
@angular-devkit/schematics 22.1.3
@angular/build 22.1.3
@angular/cli 22.1.3
@angular/common 22.1.0
@angular/compiler 22.1.0
@angular/compiler-cli 22.1.0
@angular/core 22.1.0
@angular/platform-browser 22.1.0
@angular/router 22.1.0
ng-packagr 22.1.1
rxjs 7.8.2
typescript 6.0.3
zone.js 0.16.2
rolldown 1.2.0
Anything else relevant?
The scope is wider than HTTP: the same failure applies to any prebundled dependency whose callback path crosses a native await. Counting native async/await occurrences in .angular/cache/*/app/vite/deps/ of a real application: @angular/router 24/27, @angular/core 10/9, plus several third-party packages.
The reason this is easy to misdiagnose is that ChangeDetectionStrategy.Eager looks like it should protect against it — the component is CheckAlways, but no tick is ever scheduled, so it is never traversed.
Command
serve
Is this a regression?
The previous version in which this bug was not present was
21.2.12
Description
For applications using zone.js, the CLI downlevels
async/awaitso that zone.js can follow the continuation — it can only patchZoneAwarePromise.then, never a nativeawait.Under
ng serveon v22 this downleveling is no longer applied to Vite-prebundled dependencies.FetchBackend— the default HTTP backend since v22 — does:so the remainder of
doRequest, includingobserver.next(response), resumes in the root zone. Consequence: everyHttpClientresponse callback in the application runs outside the Angular zone,NgZone.onMicrotaskEmptynever fires,ApplicationRef.tick()never runs, and any component mutating plain properties in an HTTPsubscribedoes not repaint until an unrelated DOM event happens to trigger change detection.The production build is not affected — it downlevels correctly — so this only manifests in development, which makes it hard to attribute.
Cause
getDepOptimizationConfigno longer appliesgetFeatureSupport(). In 21.2.x (packages/angular/build/src/tools/vite/utils.ts):On
main, after a583431 "refactor(@angular/build): migrate to optimizeDeps.rolldownOptions in Vite config":Neither
targetnorsupportedis passed. The same commit removed thezonelessargument fromsetupServer(...), so the information is no longer available at that point.The compensating change added in that commit, in
packages/angular/build/src/builders/dev-server/vite/index.ts:does not appear to have the intended effect: that
targetonly reaches Vite'sbuild.target, which is not what the dependency optimizer uses when prebundling.getFeatureSupportis now referenced only fromapplication-code-bundle.ts, i.e. application code — which is consistent with what I observe: app sources are still downleveled correctly, dependencies are not.Possible fix
The comment above may be outdated: rolldown 1.2.0 exposes
TransformOptions.target(oxc lowering), and oxc lists async functions among the ES2017 features it lowers. SincerolldownOptions.transformis already populated, passing the target through may be sufficient:with
targetcarrying thees2016entryserveWithVitealready computes for zone.js applications.Workaround
Re-enter the zone once in the outermost HTTP interceptor, rather than changing components:
registered first in
withInterceptors([...]), since the outermost interceptor is the last one to touch the response.Minimal Reproduction
app.config.ts— zone.js change detection plusHttpClient(zone.js is inpolyfillsby default):app.component.ts:ng serve→ console logsin Angular zone: false, and the page keeps showingwaitinguntil a DOM event triggers change detection (clicking, or moving the mouse over an element with a listener).ng build --configuration productionand servingdist/→ logsin Angular zone: trueand rendersupdatedimmediately.Adding
withXhr()also makes it work underng serve, becauseHttpXhrBackendcontains noawait— which isolates async/await downleveling as the variable.Evidence
Prebundled dependency keeps native
async/await:The same code in the production bundle is downleveled, therefore zone-safe:
Zone probe, a single HTTP call issued inside
ngZone.run()underng serve:nextFetchBackend(default)angular<root>HttpXhrBackend(withXhr())angularangularException or Error
Your Environment
Anything else relevant?
The scope is wider than HTTP: the same failure applies to any prebundled dependency whose callback path crosses a native
await. Counting nativeasync/awaitoccurrences in.angular/cache/*/app/vite/deps/of a real application:@angular/router24/27,@angular/core10/9, plus several third-party packages.The reason this is easy to misdiagnose is that
ChangeDetectionStrategy.Eagerlooks like it should protect against it — the component isCheckAlways, but no tick is ever scheduled, so it is never traversed.