Skip to content

Commit

Permalink
feat(core): Add ability to configure NgZone in `bootstrapApplicatio…
Browse files Browse the repository at this point in the history
…n` (#49557)

This commit adds a provider function that allows developers to configure
the `NgZone` instance for the application. In the future, this provider
will be used for applications to specifically opt-in to change detection
powered by ZoneJS rather than it being provided by default.

This API does _not_ specifically provide support for developers to define their own
`NgZone` implementation or opt in to `NoopNgZone` directly. Both of
these are possible today, but are effectively unsupported (applications
that use these are left to their own devices to run change detection at
the appropriate times). That said, developers can still use DI in
`bootstrapApplication` to provide an `NgZone` implementation instead,
it's just not specifically available in the
`provideZoneChangeDetection` function.

PR Close #49557
  • Loading branch information
atscott authored and dylhunn committed Mar 31, 2023
1 parent 58c1faf commit d7d6514
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 44 deletions.
9 changes: 9 additions & 0 deletions goldens/public-api/core/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,12 @@ export class NgZone {
runTask<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[], name?: string): T;
}

// @public
export interface NgZoneOptions {
eventCoalescing?: boolean;
runCoalescing?: boolean;
}

// @public
export const NO_ERRORS_SCHEMA: SchemaMetadata;

Expand Down Expand Up @@ -1158,6 +1164,9 @@ export type Provider = TypeProvider | ValueProvider | ClassProvider | Constructo
// @public
export type ProviderToken<T> = Type<T> | AbstractType<T> | InjectionToken<T>;

// @public
export function provideZoneChangeDetection(options?: NgZoneOptions): EnvironmentProviders;

// @public
export interface Query {
// (undocumented)
Expand Down
134 changes: 112 additions & 22 deletions packages/core/src/application_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {ApplicationInitStatus} from './application_init';
import {PLATFORM_INITIALIZER} from './application_tokens';
import {getCompilerFacade, JitCompilerUsage} from './compiler/compiler_facade';
import {Console} from './console';
import {ENVIRONMENT_INITIALIZER, inject} from './di';
import {ENVIRONMENT_INITIALIZER, inject, makeEnvironmentProviders} from './di';
import {Injectable} from './di/injectable';
import {InjectionToken} from './di/injection_token';
import {Injector} from './di/injector';
Expand Down Expand Up @@ -226,24 +226,20 @@ export function internalCreateApplication(config: {
// Create root application injector based on a set of providers configured at the platform
// bootstrap level as well as providers passed to the bootstrap call by a user.
const allAppProviders = [
provideNgZoneChangeDetection(new NgZone(getNgZoneOptions())),
provideZoneChangeDetection(),
...(appProviders || []),
];
const adapter = new EnvironmentNgModuleRefAdapter({
providers: allAppProviders,
parent: platformInjector as EnvironmentInjector,
debugName: NG_DEV_MODE ? 'Environment Injector' : '',
debugName: (typeof ngDevMode === 'undefined' || ngDevMode) ? 'Environment Injector' : '',
// We skip environment initializers because we need to run them inside the NgZone, which happens
// after we get the NgZone instance from the Injector.
runEnvironmentInitializers: false,
});
const envInjector = adapter.injector;
const ngZone = envInjector.get(NgZone);

// Ensure the application hasn't provided a different NgZone in its own providers
if (NG_DEV_MODE && envInjector.get(NG_ZONE_DEV_MODE) !== ngZone) {
// TODO: convert to runtime error
throw new Error('Providing `NgZone` directly in the providers is not supported.');
}

return ngZone.run(() => {
envInjector.resolveInjectorInitializers();
const exceptionHandler: ErrorHandler|null = envInjector.get(ErrorHandler, null);
Expand Down Expand Up @@ -381,6 +377,58 @@ export function getPlatform(): PlatformRef|null {
return _platformInjector?.get(PlatformRef) ?? null;
}

/**
* Used to configure event and run coalescing with `provideZoneChangeDetection`.
*
* @publicApi
*
* @see provideZoneChangeDetection
*/
export interface NgZoneOptions {
/**
* Optionally specify coalescing event change detections or not.
* Consider the following case.
*
* ```
* <div (click)="doSomething()">
* <button (click)="doSomethingElse()"></button>
* </div>
* ```
*
* When button is clicked, because of the event bubbling, both
* event handlers will be called and 2 change detections will be
* triggered. We can coalesce such kind of events to only trigger
* change detection only once.
*
* By default, this option will be false. So the events will not be
* coalesced and the change detection will be triggered multiple times.
* And if this option be set to true, the change detection will be
* triggered async by scheduling a animation frame. So in the case above,
* the change detection will only be triggered once.
*/
eventCoalescing?: boolean;

/**
* Optionally specify if `NgZone#run()` method invocations should be coalesced
* into a single change detection.
*
* Consider the following case.
* ```
* for (let i = 0; i < 10; i ++) {
* ngZone.run(() => {
* // do something
* });
* }
* ```
*
* This case triggers the change detection multiple times.
* With ngZoneRunCoalescing options, all change detections in an event loop trigger only once.
* In addition, the change detection executes in requestAnimation.
*
*/
runCoalescing?: boolean;
}

/**
* Provides additional options to the bootstrapping process.
*
Expand Down Expand Up @@ -470,14 +518,26 @@ export class PlatformRef {
// as instantiating the module creates some providers eagerly.
// So we create a mini parent injector that just contains the new NgZone and
// pass that as parent to the NgModuleFactory.
const ngZone = getNgZone(options?.ngZone, getNgZoneOptions(options));
const ngZone = getNgZone(options?.ngZone, getNgZoneOptions({
eventCoalescing: options?.ngZoneEventCoalescing,
runCoalescing: options?.ngZoneRunCoalescing
}));
// Note: Create ngZoneInjector within ngZone.run so that all of the instantiated services are
// created within the Angular zone
// Do not try to replace ngZone.run with ApplicationRef#run because ApplicationRef would then be
// created outside of the Angular zone.
return ngZone.run(() => {
const moduleRef = createNgModuleRefWithProviders(
moduleFactory.moduleType, this.injector, provideNgZoneChangeDetection(ngZone));
moduleFactory.moduleType, this.injector,
internalProvideZoneChangeDetection(() => ngZone));

if ((typeof ngDevMode === 'undefined' || ngDevMode) &&
moduleRef.injector.get(PROVIDED_NG_ZONE, null) !== null) {
throw new RuntimeError(
RuntimeErrorCode.PROVIDER_IN_WRONG_CONTEXT,
'`bootstrapModule` does not support `provideZoneChangeDetection`. Use `BootstrapOptions` instead.');
}

const exceptionHandler = moduleRef.injector.get(ErrorHandler, null);
if ((typeof ngDevMode === 'undefined' || ngDevMode) && exceptionHandler === null) {
throw new RuntimeError(
Expand Down Expand Up @@ -597,7 +657,7 @@ export class PlatformRef {
}

// Set of options recognized by the NgZone.
interface NgZoneOptions {
interface InternalNgZoneOptions {
enableLongStackTrace: boolean;
shouldCoalesceEventChangeDetection: boolean;
shouldCoalesceRunChangeDetection: boolean;
Expand All @@ -606,16 +666,16 @@ interface NgZoneOptions {
// Transforms a set of `BootstrapOptions` (supported by the NgModule-based bootstrap APIs) ->
// `NgZoneOptions` that are recognized by the NgZone constructor. Passing no options will result in
// a set of default options returned.
function getNgZoneOptions(options?: BootstrapOptions): NgZoneOptions {
function getNgZoneOptions(options?: NgZoneOptions): InternalNgZoneOptions {
return {
enableLongStackTrace: typeof ngDevMode === 'undefined' ? false : !!ngDevMode,
shouldCoalesceEventChangeDetection: options?.ngZoneEventCoalescing ?? false,
shouldCoalesceRunChangeDetection: options?.ngZoneRunCoalescing ?? false,
shouldCoalesceEventChangeDetection: options?.eventCoalescing ?? false,
shouldCoalesceRunChangeDetection: options?.runCoalescing ?? false,
};
}

function getNgZone(
ngZoneToUse: NgZone|'zone.js'|'noop' = 'zone.js', options: NgZoneOptions): NgZone {
ngZoneToUse: NgZone|'zone.js'|'noop' = 'zone.js', options: InternalNgZoneOptions): NgZone {
if (ngZoneToUse === 'noop') {
return new NoopNgZone();
}
Expand Down Expand Up @@ -1172,15 +1232,15 @@ export class NgZoneChangeDetectionScheduler {
}

/**
* Internal token used to provide verify that the NgZone in DI is the same as the one provided with
* `provideNgZoneChangeDetection`.
* Internal token used to verify that `provideZoneChangeDetection` is not used
* with the bootstrapModule API.
*/
const NG_ZONE_DEV_MODE = new InjectionToken<NgZone>(NG_DEV_MODE ? 'NG_ZONE token' : '');
const PROVIDED_NG_ZONE = new InjectionToken<boolean>(
(typeof ngDevMode === 'undefined' || ngDevMode) ? 'provideZoneChangeDetection token' : '');

export function provideNgZoneChangeDetection(ngZone: NgZone): StaticProvider[] {
export function internalProvideZoneChangeDetection(ngZoneFactory: () => NgZone): StaticProvider[] {
return [
NG_DEV_MODE ? {provide: NG_ZONE_DEV_MODE, useValue: ngZone} : [],
{provide: NgZone, useValue: ngZone},
{provide: NgZone, useFactory: ngZoneFactory},
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
Expand All @@ -1201,3 +1261,33 @@ export function provideNgZoneChangeDetection(ngZone: NgZone): StaticProvider[] {
{provide: ZONE_IS_STABLE_OBSERVABLE, useFactory: isStableFactory},
];
}

/**
* Provides `NgZone`-based change detection for the application bootstrapped using
* `bootstrapApplication`.
*
* `NgZone` is already provided in applications by default. This provider allows you to configure
* options like `eventCoalescing` in the `NgZone`.
* This provider is not available for `platformBrowser().bootstrapModule`, which uses
* `BootstrapOptions` instead.
*
* @usageNotes
* ```typescript=
* bootstrapApplication(MyApp, {providers: [
* provideZoneChangeDetection({eventCoalescing: true}),
* ]});
* ```
*
* @publicApi
* @see bootstrapApplication
* @see NgZoneOptions
*/
export function provideZoneChangeDetection(options?: NgZoneOptions): EnvironmentProviders {
const zoneProviders =
internalProvideZoneChangeDetection(() => new NgZone(getNgZoneOptions(options)));
return makeEnvironmentProviders([
(typeof ngDevMode === 'undefined' || ngDevMode) ? {provide: PROVIDED_NG_ZONE, useValue: true} :
[],
zoneProviders,
]);
}
2 changes: 1 addition & 1 deletion packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export * from './metadata';
export * from './version';
export {TypeDecorator} from './util/decorators';
export * from './di';
export {createPlatform, assertPlatform, destroyPlatform, getPlatform, BootstrapOptions, PlatformRef, ApplicationRef, createPlatformFactory, NgProbeToken, APP_BOOTSTRAP_LISTENER} from './application_ref';
export {createPlatform, assertPlatform, destroyPlatform, getPlatform, BootstrapOptions, NgZoneOptions, PlatformRef, ApplicationRef, provideZoneChangeDetection, createPlatformFactory, NgProbeToken, APP_BOOTSTRAP_LISTENER} from './application_ref';
export {enableProdMode, isDevMode} from './util/is_dev_mode';
export {APP_ID, PACKAGE_ROOT_URL, PLATFORM_INITIALIZER, PLATFORM_ID, ANIMATION_MODULE_TYPE, CSP_NONCE} from './application_tokens';
export {APP_INITIALIZER, ApplicationInitStatus} from './application_init';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

export {ALLOW_MULTIPLE_PLATFORMS as ɵALLOW_MULTIPLE_PLATFORMS, internalCreateApplication as ɵinternalCreateApplication, provideNgZoneChangeDetection as ɵprovideNgZoneChangeDetection} from './application_ref';
export {ALLOW_MULTIPLE_PLATFORMS as ɵALLOW_MULTIPLE_PLATFORMS, internalCreateApplication as ɵinternalCreateApplication} from './application_ref';
export {defaultIterableDiffers as ɵdefaultIterableDiffers, defaultKeyValueDiffers as ɵdefaultKeyValueDiffers} from './change_detection/change_detection';
export {Console as ɵConsole} from './console';
export {convertToBitFlags as ɵconvertToBitFlags, setCurrentInjector as ɵsetCurrentInjector} from './di/injector_compatibility';
Expand Down
8 changes: 4 additions & 4 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -491,9 +491,6 @@
{
"name": "NoneEncapsulationDomRenderer"
},
{
"name": "NoopNgZone"
},
{
"name": "NullInjector"
},
Expand Down Expand Up @@ -1655,6 +1652,9 @@
{
"name": "lookupTokenUsingNodeInjector"
},
{
"name": "makeEnvironmentProviders"
},
{
"name": "makeRecord"
},
Expand Down Expand Up @@ -1797,7 +1797,7 @@
"name": "promise"
},
{
"name": "provideNgZoneChangeDetection"
"name": "provideZoneChangeDetection"
},
{
"name": "redirectIfUrlTree"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,6 @@
{
"name": "NoneEncapsulationDomRenderer"
},
{
"name": "NoopNgZone"
},
{
"name": "NullInjector"
},
Expand Down Expand Up @@ -557,9 +554,6 @@
{
"name": "createElementRef"
},
{
"name": "createEnvironmentInjector"
},
{
"name": "createInjector"
},
Expand Down Expand Up @@ -930,7 +924,7 @@
"name": "promise"
},
{
"name": "provideNgZoneChangeDetection"
"name": "provideZoneChangeDetection"
},
{
"name": "refCount"
Expand Down
7 changes: 3 additions & 4 deletions packages/core/testing/src/test_bed_compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {ResourceLoader} from '@angular/compiler';
import {ApplicationInitStatus, Compiler, COMPILER_OPTIONS, Component, Directive, Injector, InjectorType, LOCALE_ID, ModuleWithComponentFactories, ModuleWithProviders, NgModule, NgModuleFactory, NgZone, Pipe, PlatformRef, Provider, resolveForwardRef, Type, ɵcompileComponent as compileComponent, ɵcompileDirective as compileDirective, ɵcompileNgModuleDefs as compileNgModuleDefs, ɵcompilePipe as compilePipe, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵDirectiveDef as DirectiveDef, ɵgetInjectableDef as getInjectableDef, ɵInternalEnvironmentProviders as InternalEnvironmentProviders, ɵisEnvironmentProviders as isEnvironmentProviders, ɵNG_COMP_DEF as NG_COMP_DEF, ɵNG_DIR_DEF as NG_DIR_DEF, ɵNG_INJ_DEF as NG_INJ_DEF, ɵNG_MOD_DEF as NG_MOD_DEF, ɵNG_PIPE_DEF as NG_PIPE_DEF, ɵNgModuleFactory as R3NgModuleFactory, ɵNgModuleTransitiveScopes as NgModuleTransitiveScopes, ɵNgModuleType as NgModuleType, ɵpatchComponentDefWithScope as patchComponentDefWithScope, ɵprovideNgZoneChangeDetection as provideNgZoneChangeDetection, ɵRender3ComponentFactory as ComponentFactory, ɵRender3NgModuleRef as NgModuleRef, ɵsetLocaleId as setLocaleId, ɵtransitiveScopesFor as transitiveScopesFor, ɵɵInjectableDeclaration as InjectableDeclaration} from '@angular/core';
import {ApplicationInitStatus, Compiler, COMPILER_OPTIONS, Component, Directive, Injector, InjectorType, LOCALE_ID, ModuleWithComponentFactories, ModuleWithProviders, NgModule, NgModuleFactory, NgZone, Pipe, PlatformRef, Provider, provideZoneChangeDetection, resolveForwardRef, Type, ɵcompileComponent as compileComponent, ɵcompileDirective as compileDirective, ɵcompileNgModuleDefs as compileNgModuleDefs, ɵcompilePipe as compilePipe, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵDirectiveDef as DirectiveDef, ɵgetInjectableDef as getInjectableDef, ɵInternalEnvironmentProviders as InternalEnvironmentProviders, ɵisEnvironmentProviders as isEnvironmentProviders, ɵNG_COMP_DEF as NG_COMP_DEF, ɵNG_DIR_DEF as NG_DIR_DEF, ɵNG_INJ_DEF as NG_INJ_DEF, ɵNG_MOD_DEF as NG_MOD_DEF, ɵNG_PIPE_DEF as NG_PIPE_DEF, ɵNgModuleFactory as R3NgModuleFactory, ɵNgModuleTransitiveScopes as NgModuleTransitiveScopes, ɵNgModuleType as NgModuleType, ɵpatchComponentDefWithScope as patchComponentDefWithScope, ɵRender3ComponentFactory as ComponentFactory, ɵRender3NgModuleRef as NgModuleRef, ɵsetLocaleId as setLocaleId, ɵtransitiveScopesFor as transitiveScopesFor, ɵɵInjectableDeclaration as InjectableDeclaration} from '@angular/core';

import {clearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution, resolveComponentResources, restoreComponentResolutionQueue} from '../../src/metadata/resource_loading';
import {ComponentDef, ComponentType} from '../../src/render3';
Expand Down Expand Up @@ -753,9 +753,8 @@ export class TestBedCompiler {
providers: [...this.rootProviderOverrides],
});

const ngZone = new NgZone({enableLongStackTrace: true});
const providers: Provider[] = [
provideNgZoneChangeDetection(ngZone),
const providers = [
provideZoneChangeDetection(),
{provide: Compiler, useFactory: () => new R3TestCompiler(this)},
...this.providers,
...this.providerOverrides,
Expand Down
38 changes: 36 additions & 2 deletions packages/platform-browser/test/browser/bootstrap_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

import {animate, style, transition, trigger} from '@angular/animations';
import {DOCUMENT, isPlatformBrowser, ɵgetDOM as getDOM} from '@angular/common';
import {ANIMATION_MODULE_TYPE, APP_INITIALIZER, Compiler, Component, createPlatformFactory, CUSTOM_ELEMENTS_SCHEMA, Directive, ErrorHandler, Inject, inject as _inject, InjectionToken, Injector, LOCALE_ID, NgModule, NgModuleRef, OnDestroy, PLATFORM_ID, PLATFORM_INITIALIZER, Provider, Sanitizer, StaticProvider, Testability, TestabilityRegistry, TransferState, Type, VERSION} from '@angular/core';
import {ApplicationRef, destroyPlatform} from '@angular/core/src/application_ref';
import {ANIMATION_MODULE_TYPE, APP_INITIALIZER, Compiler, Component, createPlatformFactory, CUSTOM_ELEMENTS_SCHEMA, Directive, ErrorHandler, Inject, inject as _inject, InjectionToken, Injector, LOCALE_ID, NgModule, NgModuleRef, NgZone, OnDestroy, PLATFORM_ID, PLATFORM_INITIALIZER, Provider, Sanitizer, StaticProvider, Testability, TestabilityRegistry, TransferState, Type, VERSION} from '@angular/core';
import {ApplicationRef, destroyPlatform, provideZoneChangeDetection} from '@angular/core/src/application_ref';
import {Console} from '@angular/core/src/console';
import {ComponentRef} from '@angular/core/src/linker/component_factory';
import {inject, TestBed} from '@angular/core/testing';
Expand Down Expand Up @@ -385,6 +385,31 @@ function bootstrap(
expect(el.innerText).toBe('Hello from AnimationCmp!');
});
});

it('initializes modules inside the NgZone when using `provideZoneChangeDetection`',
async () => {
let moduleInitialized = false;
@NgModule({})
class SomeModule {
constructor() {
expect(NgZone.isInAngularZone()).toBe(true);
moduleInitialized = true;
}
}
@Component({
template: '',
selector: 'hello-app',
imports: [SomeModule],
standalone: true,
})
class AnimationCmp {
}

await bootstrapApplication(AnimationCmp, {
providers: [provideZoneChangeDetection({eventCoalescing: true})],
});
expect(moduleInitialized).toBe(true);
});
});

it('should throw if bootstrapped Directive is not a Component', done => {
Expand Down Expand Up @@ -623,6 +648,15 @@ function bootstrap(
})();
});

it('should not allow provideZoneChangeDetection in bootstrapModule', async () => {
@NgModule({imports: [BrowserModule], providers: [provideZoneChangeDetection()]})
class SomeModule {
}

await expectAsync(platformBrowserDynamic().bootstrapModule(SomeModule))
.toBeRejectedWithError(/provideZoneChangeDetection.*BootstrapOptions/);
});

it('should register each application with the testability registry', async () => {
const ngModuleRef1: NgModuleRef<unknown> = await bootstrap(HelloRootCmp, testProviders);
const ngModuleRef2: NgModuleRef<unknown> = await bootstrap(HelloRootCmp2, testProviders);
Expand Down
Loading

0 comments on commit d7d6514

Please sign in to comment.