Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/core/src/application/application_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {profiler} from '../render3/profiler';
import {isReactiveLViewConsumer} from '../render3/reactive_lview_consumer';
import {EffectScheduler} from '../render3/reactivity/root_effect_scheduler';
import {publishDefaultGlobalUtils as _publishDefaultGlobalUtils} from '../render3/util/global_utils';
import {requiresRefreshOrTraversal} from '../render3/util/view_utils';
import {requiresRefreshOrTraversal, viewAttachedToChangeDetector} from '../render3/util/view_utils';
import {ViewRef as InternalViewRef} from '../render3/view_ref';
import {TESTABILITY} from '../testability/testability';
import {NgZone} from '../zone/ng_zone';
Expand Down Expand Up @@ -578,7 +578,9 @@ export class ApplicationRef {
this.synchronize();
if (typeof ngDevMode === 'undefined' || ngDevMode) {
for (let view of this.allViews) {
view.checkNoChanges();
if (viewAttachedToChangeDetector(view._lView)) {
view.checkNoChanges();
}
}
}
} finally {
Expand Down Expand Up @@ -648,6 +650,10 @@ export class ApplicationRef {

// Check all potentially dirty views.
for (let {_lView} of this.allViews) {
if (!viewAttachedToChangeDetector(_lView)) {
continue;
}

// When re-checking, only check views which actually need it.
if (!useGlobalCheck && !requiresRefreshOrTraversal(_lView)) {
continue;
Expand Down Expand Up @@ -713,7 +719,13 @@ export class ApplicationRef {
* reachable through traversal from our roots (e.g. it's detached from the CD tree).
*/
private syncDirtyFlagsWithViews(): void {
if (this.allViews.some(({_lView}) => requiresRefreshOrTraversal(_lView))) {
// Detached views are skipped when checking, so a dirty one must not request another pass:
// nothing would ever clear its flags and `synchronize` would loop until it gives up.
if (
this.allViews.some(
({_lView}) => viewAttachedToChangeDetector(_lView) && requiresRefreshOrTraversal(_lView),
)
) {
// If after running all afterRender callbacks new views are dirty, ensure we loop back.
this.dirtyFlags |= ApplicationRefDirtyFlags.ViewTreeTraversal;
return;
Expand Down
149 changes: 149 additions & 0 deletions packages/core/test/application_ref_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
APP_BOOTSTRAP_LISTENER,
APP_INITIALIZER,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
createComponent,
DestroyRef,
Directive,
EnvironmentInjector,
Expand All @@ -31,7 +33,9 @@ import {
NgZone,
PlatformRef,
provideZoneChangeDetection,
provideZonelessChangeDetection,
RendererFactory2,
signal,
TemplateRef,
Type,
ViewChild,
Expand Down Expand Up @@ -799,6 +803,151 @@ describe('bootstrap', () => {
expect(appRef.viewCount).toBe(0);
});

describe('views detached from change detection', () => {
@Component({
template: '{{name}}|{{sig()}}',
changeDetection: ChangeDetectionStrategy.Eager,
})
class EagerComp {
name = 'Initial';
sig = signal('Initial');
}

@Component({template: '{{name}}|{{sig()}}'})
class OnPushComp {
name = 'Initial';
sig = signal('Initial');
constructor(readonly cdr: ChangeDetectorRef) {}
}

@Component({
template: '<ng-template #t>{{name}}</ng-template>',
changeDetection: ChangeDetectionStrategy.Eager,
})
class EagerEmbeddedViewComp {
name = 'Initial';
@ViewChild('t', {static: true}) tplRef!: TemplateRef<unknown>;
}

function attach<T>(type: Type<T>) {
const appRef = TestBed.inject(ApplicationRef);
const componentRef = createComponent(type, {
environmentInjector: TestBed.inject(EnvironmentInjector),
});
appRef.attachView(componentRef.hostView);
return {appRef, componentRef, element: componentRef.location.nativeElement};
}

it('should not check a detached component view on tick', () => {
const {appRef, componentRef, element} = attach(EagerComp);
appRef.tick();
expect(element).toHaveText('Initial|Initial');

componentRef.changeDetectorRef.detach();
componentRef.instance.name = 'New';
appRef.tick();
expect(element).toHaveText('Initial|Initial');

componentRef.changeDetectorRef.reattach();
appRef.tick();
expect(element).toHaveText('New|Initial');
});

it('should not check a detached embedded view on tick', () => {
const comp = TestBed.createComponent(EagerEmbeddedViewComp);
const appRef = TestBed.inject(ApplicationRef);
const viewRef = comp.componentInstance.tplRef.createEmbeddedView({});
appRef.attachView(viewRef);
appRef.tick();
expect(viewRef.rootNodes[0].textContent).toBe('Initial');

viewRef.detach();
comp.componentInstance.name = 'New';
appRef.tick();
expect(viewRef.rootNodes[0].textContent).toBe('Initial');

viewRef.reattach();
appRef.tick();
expect(viewRef.rootNodes[0].textContent).toBe('New');
});

it('should still check a detached view when detectChanges is called on it', () => {
const {appRef, componentRef, element} = attach(EagerComp);
appRef.tick();

componentRef.changeDetectorRef.detach();
componentRef.instance.name = 'New';
componentRef.changeDetectorRef.detectChanges();
expect(element).toHaveText('New|Initial');
});

it('should not check a detached view that was marked for check', () => {
const {appRef, componentRef, element} = attach(OnPushComp);
appRef.tick();

componentRef.changeDetectorRef.detach();
componentRef.instance.name = 'New';
componentRef.instance.cdr.markForCheck();
appRef.tick();
expect(element).toHaveText('Initial|Initial');

componentRef.changeDetectorRef.reattach();
appRef.tick();
expect(element).toHaveText('New|Initial');
});

describe('zoneless', () => {
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({providers: [provideZonelessChangeDetection()]});
});

it('should not check a detached view that was marked for check', async () => {
const {appRef, componentRef, element} = attach(OnPushComp);
await appRef.whenStable();
expect(element).toHaveText('Initial|Initial');

componentRef.changeDetectorRef.detach();
componentRef.instance.name = 'New';
componentRef.instance.cdr.markForCheck();
await appRef.whenStable();
expect(element).toHaveText('Initial|Initial');

componentRef.changeDetectorRef.reattach();
await appRef.whenStable();
expect(element).toHaveText('New|Initial');
});

it('should not check a detached view when a signal it reads changes', async () => {
const {appRef, componentRef, element} = attach(OnPushComp);
await appRef.whenStable();

componentRef.changeDetectorRef.detach();
componentRef.instance.sig.set('New');
await appRef.whenStable();
expect(element).toHaveText('Initial|Initial');

componentRef.changeDetectorRef.reattach();
await appRef.whenStable();
expect(element).toHaveText('Initial|New');
});

it('should keep checking attached views while another one is detached', async () => {
const detached = attach(OnPushComp);
const attached = attach(OnPushComp);
await detached.appRef.whenStable();

detached.componentRef.changeDetectorRef.detach();
detached.componentRef.instance.sig.set('New');
attached.componentRef.instance.sig.set('New');
await detached.appRef.whenStable();

expect(detached.element).toHaveText('Initial|Initial');
expect(attached.element).toHaveText('Initial|New');
});
});
});

it('should not allow to attach a view to both, a view container and the ApplicationRef', () => {
const comp = TestBed.createComponent(MyComp);
let hostView = comp.componentRef.hostView;
Expand Down
Loading