Skip to content
Closed
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
32 changes: 21 additions & 11 deletions packages/core/src/render3/instructions/change_detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
consumerPollProducersForChange,
getActiveConsumer,
ReactiveNode,
setActiveConsumer,
} from '../../../primitives/signals';

import {RuntimeError, RuntimeErrorCode} from '../../errors';
Expand Down Expand Up @@ -207,7 +208,10 @@ export function refreshView<T>(
} else if (getActiveConsumer() === null) {
// If the current view should not have a reactive consumer but we don't have an active consumer,
// we still need to create a temporary consumer to track any signal reads in this template.
// This is a rare case that can happen with `viewContainerRef.createEmbeddedView(...).detectChanges()`.
// This is a rare case that can happen with
// - `viewContainerRef.createEmbeddedView(...).detectChanges()`.
// - `viewContainerRef.createEmbeddedView(...)` without any other dirty marking on the parent,
// flagging the parent component for traversal but not triggering a full `refreshView`.
// This temporary consumer marks the first parent that _should_ have a consumer for refresh.
// Once that refresh happens, the signals will be tracked in the parent consumer and we can destroy
// the temporary one.
Expand Down Expand Up @@ -490,16 +494,22 @@ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) {
if (shouldRefreshView) {
refreshView(tView, lView, tView.template, lView[CONTEXT]);
} else if (flags & LViewFlags.HasChildViewsToRefresh) {
if (!isInCheckNoChangesPass) {
runEffectsInView(lView);
}
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
const components = tView.components;
if (components !== null) {
detectChangesInChildComponents(lView, components, ChangeDetectionMode.Targeted);
}
if (!isInCheckNoChangesPass) {
addAfterRenderSequencesForView(lView);
// Set active consumer to null to avoid inheriting an improper reactive context
const prevConsumer = setActiveConsumer(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't comment on unchanged lines, but in refreshView, we should also update the comment that explains when getActiveConsumer() === null. There are now more situations where the consumer is null than the viewContainerRef.createEmbeddedView(...).detectChanges() edge case. That's absolutely correct -- we just need to update the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Just like to add that getActiveConsumer() === null would already be the case if you take my original bug report and add OnPush change detection to the ParentComponent

try {
if (!isInCheckNoChangesPass) {
runEffectsInView(lView);
}
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
const components = tView.components;
if (components !== null) {
detectChangesInChildComponents(lView, components, ChangeDetectionMode.Targeted);
}
if (!isInCheckNoChangesPass) {
addAfterRenderSequencesForView(lView);
}
} finally {
setActiveConsumer(prevConsumer);
}
}
}
Expand Down
63 changes: 53 additions & 10 deletions packages/core/test/acceptance/change_detection_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,21 @@ import {
QueryList,
ɵRuntimeError as RuntimeError,
ɵRuntimeErrorCode as RuntimeErrorCode,
signal,
TemplateRef,
Type,
ViewChild,
ViewChildren,
ViewContainerRef,
} from '../../src/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '../../testing';
import {
ComponentFixture,
ComponentFixtureAutoDetect,
fakeAsync,
TestBed,
tick,
} from '../../testing';
import {By} from '@angular/platform-browser';

describe('change detection', () => {
it('can provide zone and zoneless (last one wins like any other provider) in TestBed', () => {
Expand All @@ -51,7 +59,6 @@ describe('change detection', () => {
@Directive({
selector: '[viewManipulation]',
exportAs: 'vm',
standalone: false,
})
class ViewManipulation {
constructor(
Expand All @@ -76,12 +83,11 @@ describe('change detection', () => {
template: `
<ng-template #vm="vm" viewManipulation>{{'change-detected'}}</ng-template>
`,
standalone: false,
imports: [ViewManipulation],
})
class TestCmpt {}

it('should detect changes for embedded views inserted through ViewContainerRef', () => {
TestBed.configureTestingModule({declarations: [TestCmpt, ViewManipulation]});
const fixture = TestBed.createComponent(TestCmpt);
const vm = fixture.debugElement.childNodes[0].references['vm'] as ViewManipulation;

Expand All @@ -92,7 +98,6 @@ describe('change detection', () => {
});

it('should detect changes for embedded views attached to ApplicationRef', () => {
TestBed.configureTestingModule({declarations: [TestCmpt, ViewManipulation]});
const fixture = TestBed.createComponent(TestCmpt);
const vm = fixture.debugElement.childNodes[0].references['vm'] as ViewManipulation;

Expand Down Expand Up @@ -145,15 +150,14 @@ describe('change detection', () => {
<div>{{increment('componentView')}}</div>
<ng-template #vm="vm" viewManipulation>{{increment('embeddedView')}}</ng-template>
`,
standalone: false,
imports: [ViewManipulation],
})
class App {
increment(counter: 'componentView' | 'embeddedView') {
counters[counter]++;
}
}

TestBed.configureTestingModule({declarations: [App, ViewManipulation]});
const fixture = TestBed.createComponent(App);
const vm: ViewManipulation = fixture.debugElement.childNodes[1].references['vm'];
const viewRef = vm.insertIntoVcRef();
Expand All @@ -175,7 +179,7 @@ describe('change detection', () => {
@Component({
template: `<ng-template #vm="vm" viewManipulation></ng-template>`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
imports: [ViewManipulation],
})
class App {}

Expand All @@ -185,7 +189,6 @@ describe('change detection', () => {
<div>{{increment()}}</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
class DynamicComp {
increment() {
Expand All @@ -194,7 +197,6 @@ describe('change detection', () => {
noop() {}
}

TestBed.configureTestingModule({declarations: [App, ViewManipulation, DynamicComp]});
const fixture = TestBed.createComponent(App);
const vm: ViewManipulation = fixture.debugElement.childNodes[0].references['vm'];
const componentRef = vm.vcRef.createComponent(DynamicComp);
Expand All @@ -220,6 +222,47 @@ describe('change detection', () => {

expect(counter).toBe(3);
});

it('updating signal inside an EmbeddedView in a child component with OnPush inside a parent component with Default CD', async () => {
const data = signal('initial');

@Component({
selector: 'child',
template: '<ng-container *viewManipulation>{{data()}}</ng-container>',
imports: [ViewManipulation],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class ChildComponent {
data = data;
}

@Component({
template: '<child/>',
changeDetection: ChangeDetectionStrategy.Default,
imports: [ChildComponent],
})
class ParentComponent {}

TestBed.configureTestingModule({
providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],
});

const fixture = TestBed.createComponent(ParentComponent);
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toBe('');

fixture.debugElement
.queryAllNodes(By.directive(ViewManipulation))[0]
.injector.get(ViewManipulation)
.insertIntoVcRef();
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toBe(data());

data.set('new');
expect(fixture.isStable()).toBe(false);
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toBe(data());
});
});

describe('markForCheck', () => {
Expand Down