Skip to content

dev-server: Vite-prebundled dependencies keep native async/await, so every HttpClient response resumes outside the Angular zone #33770

Description

@marzoli

Command

serve

Is this a regression?

  • Yes, this behavior used to work in the previous version

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions