Skip to content

Commit

Permalink
fix(core): signals should be tracked when embeddedViewRef.detectChang…
Browse files Browse the repository at this point in the history
…es is called (#55719)

This commit fixes an issue where signals in embedded views are not
tracked if they are refreshed with `EmbeddedViewRef.detectChanges`
directly. We had previously assumed that embedded views were always
refreshed along with their hosts.

PR Close #55719
  • Loading branch information
atscott authored and AndrewKushnir committed Jun 11, 2024
1 parent 65bf5ec commit 625ca3e
Show file tree
Hide file tree
Showing 14 changed files with 326 additions and 34 deletions.
49 changes: 26 additions & 23 deletions packages/core/src/render3/instructions/change_detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import {
consumerAfterComputation,
consumerBeforeComputation,
consumerDestroy,
consumerPollProducersForChange,
getActiveConsumer,
ReactiveNode,
} from '@angular/core/primitives/signals';

Expand All @@ -29,12 +31,13 @@ import {
REACTIVE_TEMPLATE_CONSUMER,
TVIEW,
TView,
TViewType,
} from '../interfaces/view';
import {
getOrCreateTemporaryConsumer,
getOrBorrowReactiveLViewConsumer,
maybeReturnReactiveLViewConsumer,
ReactiveLViewConsumer,
viewShouldHaveReactiveConsumer,
} from '../reactive_lview_consumer';
import {
CheckNoChangesMode,
Expand Down Expand Up @@ -207,11 +210,27 @@ export function refreshView<T>(
// - We might already be in a reactive context if this is an embedded view of the host.
// - We might be descending into a view that needs a consumer.
enterView(lView);
let returnConsumerToPool = true;
let prevConsumer: ReactiveNode | null = null;
let currentConsumer: ReactiveLViewConsumer | null = null;
if (!isInCheckNoChangesPass && viewShouldHaveReactiveConsumer(tView)) {
currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
prevConsumer = consumerBeforeComputation(currentConsumer);
if (!isInCheckNoChangesPass) {
if (viewShouldHaveReactiveConsumer(tView)) {
currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
prevConsumer = consumerBeforeComputation(currentConsumer);
} 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 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.
returnConsumerToPool = false;
currentConsumer = getOrCreateTemporaryConsumer(lView);
prevConsumer = consumerBeforeComputation(currentConsumer);
} else if (lView[REACTIVE_TEMPLATE_CONSUMER]) {
consumerDestroy(lView[REACTIVE_TEMPLATE_CONSUMER]);
lView[REACTIVE_TEMPLATE_CONSUMER] = null;
}
}

try {
Expand Down Expand Up @@ -351,30 +370,14 @@ export function refreshView<T>(
} finally {
if (currentConsumer !== null) {
consumerAfterComputation(currentConsumer, prevConsumer);
maybeReturnReactiveLViewConsumer(currentConsumer);
if (returnConsumerToPool) {
maybeReturnReactiveLViewConsumer(currentConsumer);
}
}
leaveView();
}
}

/**
* Indicates if the view should get its own reactive consumer node.
*
* In the current design, all embedded views share a consumer with the component view. This allows
* us to refresh at the component level rather than at a per-view level. In addition, root views get
* their own reactive node because root component will have a host view that executes the
* component's host bindings. This needs to be tracked in a consumer as well.
*
* To get a more granular change detection than per-component, all we would just need to update the
* condition here so that a given view gets a reactive consumer which can become dirty independently
* from its parent component. For example embedded views for signal components could be created with
* a new type "SignalEmbeddedView" and the condition here wouldn't even need updating in order to
* get granular per-view change detection for signal components.
*/
function viewShouldHaveReactiveConsumer(tView: TView) {
return tView.type !== TViewType.Embedded;
}

/**
* Goes over embedded views (ones created through ViewContainerRef APIs) and refreshes
* them by executing an associated template function.
Expand Down
77 changes: 70 additions & 7 deletions packages/core/src/render3/reactive_lview_consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@

import {REACTIVE_NODE, ReactiveNode} from '@angular/core/primitives/signals';

import {LView, REACTIVE_TEMPLATE_CONSUMER} from './interfaces/view';
import {markAncestorsForTraversal} from './util/view_utils';
import {
LView,
PARENT,
REACTIVE_TEMPLATE_CONSUMER,
TVIEW,
TView,
TViewType,
} from './interfaces/view';
import {getLViewParent, markAncestorsForTraversal, markViewForRefresh} from './util/view_utils';
import {assertDefined} from '../util/assert';

let freeConsumers: ReactiveLViewConsumer[] = [];
let freeConsumers: ReactiveNode[] = [];
export interface ReactiveLViewConsumer extends ReactiveNode {
lView: LView | null;
slot: typeof REACTIVE_TEMPLATE_CONSUMER;
}

/**
Expand All @@ -27,8 +34,7 @@ export function getOrBorrowReactiveLViewConsumer(lView: LView): ReactiveLViewCon
}

function borrowReactiveLViewConsumer(lView: LView): ReactiveLViewConsumer {
const consumer: ReactiveLViewConsumer =
freeConsumers.pop() ?? Object.create(REACTIVE_LVIEW_CONSUMER_NODE);
const consumer = freeConsumers.pop() ?? Object.create(REACTIVE_LVIEW_CONSUMER_NODE);
consumer.lView = lView;
return consumer;
}
Expand All @@ -42,7 +48,7 @@ export function maybeReturnReactiveLViewConsumer(consumer: ReactiveLViewConsumer
freeConsumers.push(consumer);
}

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView' | 'slot'> = {
const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
Expand All @@ -52,3 +58,60 @@ const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView' | 'slot'
this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
},
};

/**
* Creates a temporary consumer for use with `LView`s that should not have consumers.
* If the LView already has a consumer, returns the existing one instead.
*
* This is necessary because some APIs may cause change detection directly on an LView
* that we do not want to have a consumer (Embedded views today). As a result, there
* would be no active consumer from running change detection on its host component
* and any signals in the LView template would be untracked. Instead, we create
* this temporary consumer that marks the first parent that _should_ have a consumer
* for refresh. Once change detection runs as part of that refresh, we throw away
* this consumer because its signals will then be tracked by the parent's consumer.
*/
export function getOrCreateTemporaryConsumer(lView: LView): ReactiveLViewConsumer {
const consumer = lView[REACTIVE_TEMPLATE_CONSUMER] ?? Object.create(TEMPORARY_CONSUMER_NODE);
consumer.lView = lView;
return consumer;
}

const TEMPORARY_CONSUMER_NODE = {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
let parent = getLViewParent(node.lView!);
while (parent && !viewShouldHaveReactiveConsumer(parent[TVIEW])) {
parent = getLViewParent(parent);
}
if (!parent) {
// If we can't find an appropriate parent that should have a consumer, we
// don't have a way of appropriately refreshing this LView as part of application synchronization.
return;
}

markViewForRefresh(parent);
},
consumerOnSignalRead(this: ReactiveLViewConsumer): void {
this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
},
};

/**
* Indicates if the view should get its own reactive consumer node.
*
* In the current design, all embedded views share a consumer with the component view. This allows
* us to refresh at the component level rather than at a per-view level. In addition, root views get
* their own reactive node because root component will have a host view that executes the
* component's host bindings. This needs to be tracked in a consumer as well.
*
* To get a more granular change detection than per-component, all we would just need to update the
* condition here so that a given view gets a reactive consumer which can become dirty independently
* from its parent component. For example embedded views for signal components could be created with
* a new type "SignalEmbeddedView" and the condition here wouldn't even need updating in order to
* get granular per-view change detection for signal components.
*/
export function viewShouldHaveReactiveConsumer(tView: TView) {
return tView.type !== TViewType.Embedded;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,16 @@
*/

import {NgFor, NgIf} from '@angular/common';
import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
import {
afterNextRender,
ApplicationRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
Directive,
EnvironmentInjector,
ElementRef,
inject,
Input,
PLATFORM_ID,
signal,
TemplateRef,
ViewChild,
Expand Down Expand Up @@ -803,6 +800,73 @@ describe('OnPush components with signals', () => {
fixture.detectChanges();
expect(trim(fixture.nativeElement.textContent)).toEqual('new');
});

it('tracks signal updates if embedded view is change detected directly', () => {
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ng-template #template>{{value()}}</ng-template>
`,
standalone: true,
})
class Test {
value = signal('initial');
@ViewChild('template', {static: true, read: TemplateRef})
template!: TemplateRef<{}>;
@ViewChild('template', {static: true, read: ViewContainerRef})
vcr!: ViewContainerRef;
}

const fixture = TestBed.createComponent(Test);
const appRef = TestBed.inject(ApplicationRef);
appRef.attachView(fixture.componentRef.hostView);
appRef.tick();

const viewRef = fixture.componentInstance.vcr.createEmbeddedView(
fixture.componentInstance.template,
);
viewRef.detectChanges();
expect(fixture.nativeElement.innerText).toContain('initial');

fixture.componentInstance.value.set('new');
appRef.tick();
expect(fixture.nativeElement.innerText).toContain('new');
});

it('tracks signal updates if embedded view is change detected directly before attaching', () => {
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ng-template #template>{{value()}}</ng-template>
`,
standalone: true,
})
class Test {
value = signal('initial');
@ViewChild('template', {static: true, read: TemplateRef})
template!: TemplateRef<{}>;
@ViewChild('template', {static: true, read: ViewContainerRef})
vcr!: ViewContainerRef;
element = inject(ElementRef);
}

const fixture = TestBed.createComponent(Test);
const appRef = TestBed.inject(ApplicationRef);
appRef.attachView(fixture.componentRef.hostView);
appRef.tick();

const viewRef = fixture.componentInstance.template.createEmbeddedView(
fixture.componentInstance.template,
);
fixture.componentInstance.element.nativeElement.appendChild(viewRef.rootNodes[0]);
viewRef.detectChanges();
expect(fixture.nativeElement.innerText).toContain('initial');
fixture.componentInstance.vcr.insert(viewRef);

fixture.componentInstance.value.set('new');
appRef.tick();
expect(fixture.nativeElement.innerText).toContain('new');
});
});

describe('shielded by non-dirty OnPush', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,9 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
Expand Down Expand Up @@ -491,6 +494,9 @@
{
"name": "Subscription"
},
{
"name": "TEMPORARY_CONSUMER_NODE"
},
{
"name": "TESTABILITY"
},
Expand Down Expand Up @@ -746,6 +752,12 @@
{
"name": "configureViewWithDirective"
},
{
"name": "consumerBeforeComputation"
},
{
"name": "consumerDestroy"
},
{
"name": "consumerIsLive"
},
Expand Down Expand Up @@ -1454,6 +1466,9 @@
{
"name": "viewAttachedToChangeDetector"
},
{
"name": "viewShouldHaveReactiveConsumer"
},
{
"name": "visitDslNode"
},
Expand Down
15 changes: 15 additions & 0 deletions packages/core/test/bundling/animations/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
Expand Down Expand Up @@ -533,6 +536,9 @@
{
"name": "Subscription"
},
{
"name": "TEMPORARY_CONSUMER_NODE"
},
{
"name": "TESTABILITY"
},
Expand Down Expand Up @@ -803,6 +809,12 @@
{
"name": "configureViewWithDirective"
},
{
"name": "consumerBeforeComputation"
},
{
"name": "consumerDestroy"
},
{
"name": "consumerIsLive"
},
Expand Down Expand Up @@ -1529,6 +1541,9 @@
{
"name": "viewAttachedToChangeDetector"
},
{
"name": "viewShouldHaveReactiveConsumer"
},
{
"name": "visitDslNode"
},
Expand Down
Loading

0 comments on commit 625ca3e

Please sign in to comment.