diff --git a/packages/core/src/application/create_application.ts b/packages/core/src/application/create_application.ts index b1b765fc1c0..c9970197d69 100644 --- a/packages/core/src/application/create_application.ts +++ b/packages/core/src/application/create_application.ts @@ -8,7 +8,7 @@ import {Subscription} from 'rxjs'; -import {provideZoneChangeDetection} from '../change_detection/scheduling'; +import {provideZoneChangeDetection} from '../change_detection/scheduling/ng_zone_scheduling'; import {EnvironmentProviders, Provider, StaticProvider} from '../di/interface/provider'; import {EnvironmentInjector} from '../di/r3_injector'; import {ErrorHandler} from '../error_handler'; diff --git a/packages/core/src/change_detection/scheduling.ts b/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts similarity index 95% rename from packages/core/src/change_detection/scheduling.ts rename to packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts index 3231df31f29..0c91dc58f41 100644 --- a/packages/core/src/change_detection/scheduling.ts +++ b/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts @@ -8,13 +8,13 @@ import {Subscription} from 'rxjs'; -import {ApplicationRef} from '../application/application_ref'; -import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, Injectable, InjectionToken, makeEnvironmentProviders, StaticProvider} from '../di'; -import {ErrorHandler, INTERNAL_APPLICATION_ERROR_HANDLER} from '../error_handler'; -import {RuntimeError, RuntimeErrorCode} from '../errors'; -import {PendingTasks} from '../pending_tasks'; -import {NgZone} from '../zone'; -import {InternalNgZoneOptions} from '../zone/ng_zone'; +import {ApplicationRef} from '../../application/application_ref'; +import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, Injectable, InjectionToken, makeEnvironmentProviders, StaticProvider} from '../../di'; +import {ErrorHandler, INTERNAL_APPLICATION_ERROR_HANDLER} from '../../error_handler'; +import {RuntimeError, RuntimeErrorCode} from '../../errors'; +import {PendingTasks} from '../../pending_tasks'; +import {NgZone} from '../../zone'; +import {InternalNgZoneOptions} from '../../zone/ng_zone'; @Injectable({providedIn: 'root'}) export class NgZoneChangeDetectionScheduler { diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts new file mode 100644 index 00000000000..53e181a2a7d --- /dev/null +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Injectable that is notified when an `LView` is made aware of changes to application state. + */ +export abstract class ChangeDetectionScheduler { + abstract notify(): void; +} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 29835e40408..e58577d376f 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -25,7 +25,7 @@ export * from './di'; export {BootstrapOptions, ApplicationRef, NgProbeToken, APP_BOOTSTRAP_LISTENER} from './application/application_ref'; export {PlatformRef} from './platform/platform_ref'; export {createPlatform, createPlatformFactory, assertPlatform, destroyPlatform, getPlatform} from './platform/platform'; -export {provideZoneChangeDetection, NgZoneOptions} from './change_detection/scheduling'; +export {provideZoneChangeDetection, NgZoneOptions} from './change_detection/scheduling/ng_zone_scheduling'; 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/application_tokens'; export {APP_INITIALIZER, ApplicationInitStatus} from './application/application_init'; diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index bb5d2cd78c6..97eab3fd7f2 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -12,6 +12,7 @@ export {IMAGE_CONFIG as ɵIMAGE_CONFIG, IMAGE_CONFIG_DEFAULTS as ɵIMAGE_CONFIG_ export {internalCreateApplication as ɵinternalCreateApplication} from './application/create_application'; export {defaultIterableDiffers as ɵdefaultIterableDiffers, defaultKeyValueDiffers as ɵdefaultKeyValueDiffers} from './change_detection/change_detection'; export {getEnsureDirtyViewsAreAlwaysReachable as ɵgetEnsureDirtyViewsAreAlwaysReachable, setEnsureDirtyViewsAreAlwaysReachable as ɵsetEnsureDirtyViewsAreAlwaysReachable} from './change_detection/flags'; +export {ChangeDetectionScheduler as ɵChangeDetectionScheduler} from './change_detection/scheduling/zoneless_scheduling'; export {Console as ɵConsole} from './console'; export {DeferBlockDetails as ɵDeferBlockDetails, getDeferBlocks as ɵgetDeferBlocks} from './defer/discovery'; export {renderDeferBlockState as ɵrenderDeferBlockState, triggerResourceLoading as ɵtriggerResourceLoading} from './defer/instructions'; diff --git a/packages/core/src/platform/platform_ref.ts b/packages/core/src/platform/platform_ref.ts index 0d157f0a409..56a7d6716ab 100644 --- a/packages/core/src/platform/platform_ref.ts +++ b/packages/core/src/platform/platform_ref.ts @@ -8,7 +8,7 @@ import {ApplicationInitStatus} from '../application/application_init'; import {_callAndReportToErrorHandler, ApplicationRef, BootstrapOptions, compileNgModuleFactory, optionsReducer, remove} from '../application/application_ref'; -import {getNgZoneOptions, internalProvideZoneChangeDetection, PROVIDED_NG_ZONE} from '../change_detection/scheduling'; +import {getNgZoneOptions, internalProvideZoneChangeDetection, PROVIDED_NG_ZONE} from '../change_detection/scheduling/ng_zone_scheduling'; import {Injectable, InjectionToken, Injector} from '../di'; import {ErrorHandler} from '../error_handler'; import {RuntimeError, RuntimeErrorCode} from '../errors'; diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 40be1e456b4..29d9fecfba1 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -7,6 +7,7 @@ */ import {ChangeDetectorRef} from '../change_detection/change_detector_ref'; +import {ChangeDetectionScheduler} from '../change_detection/scheduling/zoneless_scheduling'; import {Injector} from '../di/injector'; import {convertToBitFlags} from '../di/injector_compatibility'; import {InjectFlags, InjectOptions} from '../di/interface/injector'; @@ -201,6 +202,7 @@ export class ComponentFactory extends AbstractComponentFactory { const sanitizer = rootViewInjector.get(Sanitizer, null); const afterRenderEventManager = rootViewInjector.get(AfterRenderEventManager, null); + const changeDetectionScheduler = rootViewInjector.get(ChangeDetectionScheduler, null); const environment: LViewEnvironment = { rendererFactory, @@ -208,6 +210,7 @@ export class ComponentFactory extends AbstractComponentFactory { // We don't use inline effects (yet). inlineEffectRunner: null, afterRenderEventManager, + changeDetectionScheduler, }; const hostRenderer = rendererFactory.createRenderer(null, this.componentDef); diff --git a/packages/core/src/render3/instructions/mark_view_dirty.ts b/packages/core/src/render3/instructions/mark_view_dirty.ts index 67cbf9ad253..7e3556aa249 100644 --- a/packages/core/src/render3/instructions/mark_view_dirty.ts +++ b/packages/core/src/render3/instructions/mark_view_dirty.ts @@ -7,7 +7,7 @@ */ import {isRootView} from '../interfaces/type_checks'; -import {FLAGS, LView, LViewFlags} from '../interfaces/view'; +import {ENVIRONMENT, FLAGS, LView, LViewFlags} from '../interfaces/view'; import {getLViewParent} from '../util/view_traversal_utils'; /** @@ -22,6 +22,7 @@ import {getLViewParent} from '../util/view_traversal_utils'; * @returns the root LView */ export function markViewDirty(lView: LView): LView|null { + lView[ENVIRONMENT].changeDetectionScheduler?.notify(); while (lView) { lView[FLAGS] |= LViewFlags.Dirty; const parent = getLViewParent(lView); diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index afdc38edfa2..8cf8ef5ee03 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -24,6 +24,7 @@ import {Renderer, RendererFactory} from './renderer'; import {RElement} from './renderer_dom'; import {TStylingKey, TStylingRange} from './styling'; import {TDeferBlockDetails} from '../../defer/interfaces'; +import type {ChangeDetectionScheduler} from '../../change_detection/scheduling/zoneless_scheduling'; @@ -372,6 +373,9 @@ export interface LViewEnvironment { /** Container for after render hooks */ afterRenderEventManager: AfterRenderEventManager|null; + + /** Scheduler for change detection to notify when application state changes. */ + changeDetectionScheduler: ChangeDetectionScheduler|null; } /** Flags associated with an LView (saved in LView[FLAGS]) */ diff --git a/packages/core/src/render3/util/view_utils.ts b/packages/core/src/render3/util/view_utils.ts index 2648ae98566..23ab62595a8 100644 --- a/packages/core/src/render3/util/view_utils.ts +++ b/packages/core/src/render3/util/view_utils.ts @@ -14,7 +14,7 @@ import {LContainer, LContainerFlags, TYPE} from '../interfaces/container'; import {TConstants, TNode} from '../interfaces/node'; import {RNode} from '../interfaces/renderer_dom'; import {isLContainer, isLView} from '../interfaces/type_checks'; -import {DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, ON_DESTROY_HOOKS, PARENT, PREORDER_HOOK_FLAGS, PreOrderHookFlags, REACTIVE_TEMPLATE_CONSUMER, TData, TView} from '../interfaces/view'; +import {DECLARATION_VIEW, ENVIRONMENT, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, ON_DESTROY_HOOKS, PARENT, PREORDER_HOOK_FLAGS, PreOrderHookFlags, REACTIVE_TEMPLATE_CONSUMER, TData, TView} from '../interfaces/view'; @@ -207,17 +207,19 @@ export function requiresRefreshOrTraversal(lView: LView) { * parents above. */ export function updateAncestorTraversalFlagsOnAttach(lView: LView) { - // When we attach a view that's marked `Dirty`, we should ensure that it is reached during the - // next CD traversal so we add the `RefreshView` flag and mark ancestors accordingly. - if (lView[FLAGS] & LViewFlags.Dirty && getEnsureDirtyViewsAreAlwaysReachable()) { - lView[FLAGS] |= LViewFlags.RefreshView; + // TODO(atscott): Simplify if...else cases once getEnsureDirtyViewsAreAlwaysReachable is always + // `true`. When we attach a view that's marked `Dirty`, we should ensure that it is reached during + // the next CD traversal so we add the `RefreshView` flag and mark ancestors accordingly. + if (requiresRefreshOrTraversal(lView)) { + markAncestorsForTraversal(lView); + } else if (lView[FLAGS] & LViewFlags.Dirty) { + if (getEnsureDirtyViewsAreAlwaysReachable()) { + lView[FLAGS] |= LViewFlags.RefreshView; + markAncestorsForTraversal(lView); + } else { + lView[ENVIRONMENT].changeDetectionScheduler?.notify(); + } } - - if (!requiresRefreshOrTraversal(lView)) { - return; - } - - markAncestorsForTraversal(lView); } /** @@ -228,6 +230,7 @@ export function updateAncestorTraversalFlagsOnAttach(lView: LView) { * flag is already `true` or the `lView` is detached. */ export function markAncestorsForTraversal(lView: LView) { + lView[ENVIRONMENT].changeDetectionScheduler?.notify(); let parent = lView[PARENT]; while (parent !== null) { // We stop adding markers to the ancestors once we reach one that already has the marker. This diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index 21ffe85648f..8e97f873a1c 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -17,9 +17,9 @@ import {checkNoChangesInternal, detectChangesInternal} from './instructions/chan import {markViewDirty} from './instructions/mark_view_dirty'; import {CONTAINER_HEADER_OFFSET, VIEW_REFS} from './interfaces/container'; import {isLContainer} from './interfaces/type_checks'; -import {CONTEXT, FLAGS, LView, LViewFlags, PARENT, TVIEW} from './interfaces/view'; +import {CONTEXT, ENVIRONMENT, FLAGS, LView, LViewFlags, PARENT, TVIEW} from './interfaces/view'; import {destroyLView, detachView, detachViewFromDOM} from './node_manipulation'; -import {storeLViewOnDestroy, updateAncestorTraversalFlagsOnAttach} from './util/view_utils'; +import {markAncestorsForTraversal, storeLViewOnDestroy, updateAncestorTraversalFlagsOnAttach} from './util/view_utils'; // Needed due to tsickle downleveling where multiple `implements` with classes creates @@ -326,5 +326,6 @@ export class ViewRef implements EmbeddedViewRef, ChangeDetectorRefInterfac ngDevMode && 'This view is already attached to a ViewContainer!'); } this._appRef = appRef; + updateAncestorTraversalFlagsOnAttach(this._lView); } } diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 7504c7a8414..1729508d107 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -116,6 +116,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, @@ -1361,6 +1364,9 @@ { "name": "unwrapRNode" }, + { + "name": "updateAncestorTraversalFlagsOnAttach" + }, { "name": "updateMicroTaskStatus" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index c07a6e50b61..f6433b2f95f 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -137,6 +137,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, @@ -1433,6 +1436,9 @@ { "name": "unwrapRNode" }, + { + "name": "updateAncestorTraversalFlagsOnAttach" + }, { "name": "updateMicroTaskStatus" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index d00c748810b..e0604fbcc32 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -68,6 +68,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, @@ -1139,6 +1142,9 @@ { "name": "unwrapRNode" }, + { + "name": "updateAncestorTraversalFlagsOnAttach" + }, { "name": "updateMicroTaskStatus" }, diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index d58e882b086..cd12a922492 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -71,6 +71,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, @@ -1577,6 +1580,9 @@ { "name": "init_ng_zone" }, + { + "name": "init_ng_zone_scheduling" + }, { "name": "init_node" }, @@ -1712,9 +1718,6 @@ { "name": "init_sanitizer" }, - { - "name": "init_scheduling" - }, { "name": "init_schema" }, @@ -1892,6 +1895,9 @@ { "name": "init_zone" }, + { + "name": "init_zoneless_scheduling" + }, { "name": "initializeDirectives" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 7e717c57f20..157ee7c8922 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -95,6 +95,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 84b8b2283fa..b54f942dee6 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -98,6 +98,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 5ba2f26316a..4e9647c71f6 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -44,6 +44,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ComponentFactory" }, @@ -890,6 +893,9 @@ { "name": "unwrapRNode" }, + { + "name": "updateAncestorTraversalFlagsOnAttach" + }, { "name": "updateMicroTaskStatus" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 54c656e173d..16d9054c062 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -68,6 +68,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, @@ -1271,6 +1274,9 @@ { "name": "unwrapRNode" }, + { + "name": "updateAncestorTraversalFlagsOnAttach" + }, { "name": "updateMicroTaskStatus" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 0bbdc4d5a5d..082b61110e9 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -101,6 +101,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 5b6a4ec8764..fc3dcb2109f 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -56,6 +56,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, @@ -992,6 +995,9 @@ { "name": "unwrapRNode" }, + { + "name": "updateAncestorTraversalFlagsOnAttach" + }, { "name": "updateMicroTaskStatus" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 2356b57f82e..8c741889df1 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -68,6 +68,9 @@ { "name": "ChainedInjector" }, + { + "name": "ChangeDetectionScheduler" + }, { "name": "ChangeDetectionStrategy" }, diff --git a/packages/core/test/change_detection_scheduler_spec.ts b/packages/core/test/change_detection_scheduler_spec.ts new file mode 100644 index 00000000000..960267da6af --- /dev/null +++ b/packages/core/test/change_detection_scheduler_spec.ts @@ -0,0 +1,303 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AsyncPipe} from '@angular/common'; +import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id'; +import {afterNextRender, ApplicationRef, ChangeDetectorRef, Component, ComponentRef, createComponent, DebugElement, ElementRef, EnvironmentInjector, ErrorHandler, getDebugNode, inject, Injectable, Input, NgZone, PLATFORM_ID, signal, TemplateRef, Type, ViewChild, ViewContainerRef, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵNoopNgZone} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {BehaviorSubject} from 'rxjs'; + +@Injectable({providedIn: 'root'}) +class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { + private appRef = inject(ApplicationRef); + private _hasPendingChangeDetection = false; + get hasPendingChangeDetection() { + return this._hasPendingChangeDetection; + } + + notify(): void { + if (this._hasPendingChangeDetection) return; + + this._hasPendingChangeDetection = true; + setTimeout(() => { + try { + this.appRef.tick(); + } finally { + this._hasPendingChangeDetection = false; + } + }); + } +} + + +describe('Angular with NoopNgZone', () => { + function nextRender(): Promise { + const injector = TestBed.inject(EnvironmentInjector); + return new Promise((resolve) => { + afterNextRender(resolve, {injector}); + }); + } + + async function createAndAttachComponent(type: Type): Promise> { + const environmentInjector = TestBed.inject(EnvironmentInjector); + const component = createComponent(type, {environmentInjector}); + environmentInjector.get(ApplicationRef).attachView(component.hostView); + await nextRender(); + return component; + } + + describe('notifies scheduler', () => { + let scheduler: ChangeDetectionSchedulerImpl; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + {provide: NgZone, useClass: ɵNoopNgZone}, + {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}, + {provide: ChangeDetectionScheduler, useExisting: ChangeDetectionSchedulerImpl}, + ] + }); + scheduler = TestBed.inject(ChangeDetectionSchedulerImpl); + }); + + it('when signal updates', async () => { + const val = signal('initial'); + @Component({template: '{{val()}}', standalone: true}) + class TestComponent { + val = val; + } + + const component = await createAndAttachComponent(TestComponent); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + val.set('new'); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('new'); + }); + + it('when using markForCheck()', async () => { + @Component({template: '{{val}}', standalone: true}) + class TestComponent { + cdr = inject(ChangeDetectorRef); + val = 'initial'; + setVal(val: string) { + this.val = val; + this.cdr.markForCheck(); + } + } + + const component = await createAndAttachComponent(TestComponent); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + component.instance.setVal('new'); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('new'); + }); + + it('on input binding', async () => { + @Component({template: '{{val}}', standalone: true}) + class TestComponent { + @Input() val = 'initial'; + } + + const component = await createAndAttachComponent(TestComponent); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + component.setInput('val', 'new'); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('new'); + }); + + it('on event listener bound in template', async () => { + @Component({template: '
{{val}}
', standalone: true}) + class TestComponent { + val = 'initial'; + + updateVal() { + this.val = 'new'; + } + } + + const component = await createAndAttachComponent(TestComponent); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + getDebugElement(component) + .query(p => p.nativeElement.tagName === 'DIV') + .triggerEventHandler('click'); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('new'); + }); + + it('on event listener bound in host', async () => { + @Component({host: {'(click)': 'updateVal()'}, template: '{{val}}', standalone: true}) + class TestComponent { + val = 'initial'; + + updateVal() { + this.val = 'new'; + } + } + + const component = await createAndAttachComponent(TestComponent); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + getDebugElement(component).triggerEventHandler('click'); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('new'); + }); + + it('with async pipe', async () => { + @Component({template: '{{val | async}}', standalone: true, imports: [AsyncPipe]}) + class TestComponent { + val = new BehaviorSubject('initial'); + } + + const component = await createAndAttachComponent(TestComponent); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + component.instance.val.next('new'); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('new'); + }); + + it('when creating a view', async () => { + @Component({ + template: '{{"binding"}}', + standalone: true, + }) + class TestComponent { + @ViewChild(TemplateRef) template!: TemplateRef; + @ViewChild('ref', {read: ViewContainerRef}) viewContainer!: ViewContainerRef; + + createView(): any { + this.viewContainer.createEmbeddedView(this.template); + } + } + + const component = await createAndAttachComponent(TestComponent); + expect(scheduler.hasPendingChangeDetection).toBe(false); + + component.instance.createView(); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('binding'); + }); + + it('when inserting a view', async () => { + @Component({ + template: '{{"binding"}}', + standalone: true, + }) + class DynamicCmp { + elementRef = inject(ElementRef); + } + @Component({ + template: '', + standalone: true, + }) + class TestComponent { + @ViewChild('ref', {read: ViewContainerRef}) viewContainer!: ViewContainerRef; + } + + const fixture = await createAndAttachComponent(TestComponent); + expect(scheduler.hasPendingChangeDetection).toBe(false); + + const component = + createComponent(DynamicCmp, {environmentInjector: TestBed.inject(EnvironmentInjector)}); + fixture.instance.viewContainer.insert(component.hostView); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(fixture.location.nativeElement.innerText).toEqual('binding'); + }); + + it('when attaching view to ApplicationRef', async () => { + @Component({ + template: '{{"binding"}}', + standalone: true, + }) + class DynamicCmp { + elementRef = inject(ElementRef); + } + + const environmentInjector = TestBed.inject(EnvironmentInjector); + const appRef = TestBed.inject(ApplicationRef); + const component = createComponent(DynamicCmp, {environmentInjector}); + expect(scheduler.hasPendingChangeDetection).toBe(false); + expect(component.instance.elementRef.nativeElement.innerText).toEqual(''); + + appRef.attachView(component.hostView); + expect(scheduler.hasPendingChangeDetection).toBe(true); + await nextRender(); + expect(component.instance.elementRef.nativeElement.innerText).toEqual('binding'); + + // Don't need to run CD on detach because DOM nodes are just removed + // That said, queries need to be updated and currently only update during CD but that's an + // unrelated change that needs to happen. + appRef.detachView(component.hostView); + expect(scheduler.hasPendingChangeDetection).toBe(false); + // reattaching non-dirty view does not notify scheduler + appRef.attachView(component.hostView); + expect(scheduler.hasPendingChangeDetection).toBe(false); + }); + }); + + it('can recover when an error is re-thrown by the ErrorHandler', async () => { + const val = signal('initial'); + let throwError = false; + @Component({template: '{{val()}}{{maybeThrow()}}', standalone: true}) + class TestComponent { + val = val; + maybeThrow() { + if (throwError) { + throw new Error('e'); + } else { + return ''; + } + } + } + TestBed.configureTestingModule({ + providers: [ + {provide: NgZone, useClass: ɵNoopNgZone}, + {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}, + {provide: ChangeDetectionScheduler, useExisting: ChangeDetectionSchedulerImpl}, + { + provide: ErrorHandler, useClass: class extends ErrorHandler { + override handleError(error: any): void { + throw error; + } + } + }, + ] + }); + + const component = await createAndAttachComponent(TestComponent); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + val.set('new'); + throwError = true; + // error is thrown in a timeout and can't really be "caught". + // Still need to wrap in expect so it happens in the expect context and doesn't fail the test. + expect(async () => await nextRender()).not.toThrow(); + expect(component.location.nativeElement.innerText).toEqual('initial'); + + throwError = false; + await nextRender(); + expect(component.location.nativeElement.innerText).toEqual('new'); + }); +}); + + +function getDebugElement(component: ComponentRef) { + return getDebugNode(component.location.nativeElement) as DebugElement; +} diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index 4c517239d7d..de3e8c37ffe 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -147,6 +147,7 @@ describe('di', () => { sanitizer: null, inlineEffectRunner: null, afterRenderEventManager: null, + changeDetectionScheduler: null, }, {} as any, null, null, null); enterView(contentView); diff --git a/packages/core/test/render3/instructions/shared_spec.ts b/packages/core/test/render3/instructions/shared_spec.ts index 5013f6405c0..b6167687ed7 100644 --- a/packages/core/test/render3/instructions/shared_spec.ts +++ b/packages/core/test/render3/instructions/shared_spec.ts @@ -42,8 +42,13 @@ export function enterViewWithOneDiv() { tView.expandoStartIndex = HEADER_OFFSET + 10; const tNode = tView.firstChild = createTNode(tView, null!, TNodeType.Element, 0, 'div', null); const lView = createLView( - null, tView, null, LViewFlags.CheckAlways, null, null, - {rendererFactory, sanitizer: null, inlineEffectRunner: null, afterRenderEventManager: null}, + null, tView, null, LViewFlags.CheckAlways, null, null, { + rendererFactory, + sanitizer: null, + inlineEffectRunner: null, + afterRenderEventManager: null, + changeDetectionScheduler: null + }, renderer, null, null, null); lView[HEADER_OFFSET] = div; tView.data[HEADER_OFFSET] = tNode; diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index 668398cc063..b20aec610b5 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -76,6 +76,7 @@ export class ViewFixture { sanitizer: sanitizer || null, afterRenderEventManager: new AfterRenderEventManager(), inlineEffectRunner: null, + changeDetectionScheduler: null, }, hostRenderer, null, null, null);