Skip to content

Commit 4625046

Browse files
authored
fix(elements): template render part connectivity on embedded view destroy (#15784)
1 parent 6ea8537 commit 4625046

File tree

3 files changed

+91
-18
lines changed

3 files changed

+91
-18
lines changed

projects/igniteui-angular-elements/src/app/wrapper/template-ref-wrapper.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,37 @@ const PREFIX_IMPLICIT_PROP = '$implicit';
66

77
/**
88
* Wraps a template ref and exposes the entire context to the template as additional prop
9+
* @internal
910
*/
1011
export class TemplateRefWrapper<C extends object> extends TemplateRef<C> {
1112

12-
get elementRef(): ElementRef<any> {
13+
public override get elementRef(): ElementRef<any> {
1314
return this.innerTemplateRef.elementRef;
1415
}
1516

1617
/** Create a wrapper around TemplateRef with the context exposed */
17-
constructor(public innerTemplateRef: TemplateRef<C>, private _templateFunction: any) {
18+
constructor(public innerTemplateRef: TemplateRef<C>, private _templateFunction: any, private embeddedViewDestroyCallback: (container: HTMLElement) => void) {
1819
super();
1920
}
2021

2122
private _contentContext = new Map<string, TemplateRefWrapperContentContext>();
2223

23-
override createEmbeddedView(context: C, injector?: Injector): EmbeddedViewRef<C> {
24+
public override createEmbeddedView(context: C, injector?: Injector): EmbeddedViewRef<C> {
2425
return this.createEmbeddedViewImpl(context, injector);
2526
}
2627

2728
/** @internal Angular 16 impl gets called directly... */
28-
createEmbeddedViewImpl(context: C, injector?: Injector, _hydrationInfo: any = null): EmbeddedViewRef<C> {
29+
public createEmbeddedViewImpl(context: C, injector?: Injector, _hydrationInfo: any = null): EmbeddedViewRef<C> {
2930
//#region bridged template props
30-
let isBridged = !!this._templateFunction.___isBridged;
31+
const isBridged = !!this._templateFunction.___isBridged;
3132
let contentContext: TemplateRefWrapperContentContext;
3233
let contentId: string;
3334
let root: any;
3435
//#endregion
3536

3637
// https://github.com/angular/angular/pull/51887
3738
/** Angular 17+ context is behind a proxy: will throw on set for templates without context & underlying object will change, so Proxy extra props on top */
38-
let ctx = <C>new Proxy(context, {
39+
const ctx = <C>new Proxy(context, {
3940
has(_target, prop): boolean {
4041
if (prop === IMPLICIT_PROP) {
4142
return true;
@@ -79,24 +80,28 @@ export class TemplateRefWrapper<C extends object> extends TemplateRef<C> {
7980
this._contentContext.set(contentId, contentContext);
8081
this._templateFunction.___onTemplateInit(this._templateFunction, root, contentContext);
8182
//contentContext.templateFunction.___onTemplateContextChanged(contentContext.templateFunction, contentContext.root, context);
83+
}
8284

83-
viewRef.onDestroy(() => {
85+
viewRef.onDestroy(() => {
86+
this.embeddedViewDestroyCallback(viewRef.rootNodes[0]);
87+
if (isBridged) {
8488
this.destroyingBridgedView(contentContext);
85-
});
86-
}
89+
}
90+
});
8791

8892
return viewRef;
8993
}
9094

91-
destroyingBridgedView(contentContext: TemplateRefWrapperContentContext) {
95+
private destroyingBridgedView(contentContext: TemplateRefWrapperContentContext) {
9296
this._templateFunction.___onTemplateTeardown(this._templateFunction, contentContext.root, contentContext);
9397
this._contentContext.delete(contentContext._id);
9498
}
9599

96100
}
97101

102+
/** @internal */
98103
class TemplateRefWrapperContentContext {
99-
_id: string;
100-
root: any;
101-
templateFunction: any;
104+
public _id: string;
105+
public root: any;
106+
public templateFunction: any;
102107
}

projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
2-
2+
import { html } from 'lit-html';
3+
import { AsyncDirective, directive } from 'lit/async-directive.js';
34
import { TemplateWrapperComponent } from './wrapper.component';
45

6+
const directiveLog = [];
7+
class ToLowerAsyncDirective extends AsyncDirective {
8+
public override render(text: string) {
9+
directiveLog.push('render');
10+
return text.toLocaleLowerCase();
11+
}
12+
13+
protected override disconnected(): void {
14+
directiveLog.push('disconnected');
15+
}
16+
}
17+
export const toLowerAsync = directive(ToLowerAsyncDirective);
18+
519
describe('WrapperComponent', () => {
620
let component: TemplateWrapperComponent;
721
let fixture: ComponentFixture<TemplateWrapperComponent>;
@@ -22,4 +36,31 @@ describe('WrapperComponent', () => {
2236
it('should create', () => {
2337
expect(component).toBeTruthy();
2438
});
39+
40+
it('should render template', () => {
41+
const context = { text: "Oh hi" };
42+
const templateRef = component.addTemplate((ctx) => html`<span id="template1">${ctx.text}</span>`);
43+
const embeddedView = templateRef.createEmbeddedView(context);
44+
embeddedView.detectChanges();
45+
46+
const span = embeddedView.rootNodes[0].querySelector("#template1");
47+
expect(span).toBeDefined();
48+
expect(span.textContent).toBe(context.text);
49+
});
50+
51+
52+
it('should update connectivity on template with AsyncDirective', () => {
53+
const context = { text: "OH HI" };
54+
const templateRef = component.addTemplate((ctx) => html`<span id="template1">${toLowerAsync(ctx.text)}</span>`);
55+
const embeddedView = templateRef.createEmbeddedView(context);
56+
embeddedView.detectChanges();
57+
58+
const span = embeddedView.rootNodes[0].querySelector("#template1");
59+
expect(span).toBeDefined();
60+
expect(span.textContent).toBe(context.text.toLocaleLowerCase());
61+
expect(directiveLog).toEqual(['render']);
62+
63+
embeddedView.destroy();
64+
expect(directiveLog).toEqual(['render', 'disconnected']);
65+
});
2566
});

projects/igniteui-angular-elements/src/app/wrapper/wrapper.component.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, QueryList, TemplateRef, ViewChildren } fr
22
import { Subject } from 'rxjs';
33
import { TemplateRefWrapper } from './template-ref-wrapper';
44

5-
import { render, TemplateResult } from 'lit-html';
5+
import { render, type RootPart, type TemplateResult } from 'lit-html';
66

77
type TemplateFunction = (arg: any) => TemplateResult;
88

@@ -17,6 +17,8 @@ export class TemplateWrapperComponent {
1717
public templateFunctions: TemplateFunction[] = [];
1818
public templateRendered = new Subject<HTMLElement>();
1919

20+
private childParts: WeakMap<HTMLElement, RootPart> = new WeakMap();
21+
2022
/**
2123
* All template refs
2224
* Warning: the first is always the root `@for` template
@@ -27,15 +29,25 @@ export class TemplateWrapperComponent {
2729

2830
constructor(private cdr: ChangeDetectorRef) { }
2931

30-
public litRender(container: HTMLElement, templateFunc: (arg: any) => TemplateResult, arg: any) {
31-
render(templateFunc(arg), container);
32+
protected litRender(container: HTMLElement, templateFunc: (arg: any) => TemplateResult, arg: any) {
33+
const part = render(templateFunc(arg), container);
34+
35+
let existingPart = this.childParts.get(container);
36+
if (existingPart && existingPart !== part) {
37+
// should be just one per container, this might be redundant
38+
this.embeddedViewDestroyCallback(container);
39+
existingPart = undefined;
40+
}
41+
if (!existingPart) {
42+
this.childParts.set(container, part);
43+
}
3244
this.templateRendered.next(container);
3345
}
3446

3547
public addTemplate(templateFunc: TemplateFunction): TemplateRef<any> {
3648
this.templateFunctions.push(templateFunc);
3749
this.cdr.detectChanges();
38-
return new TemplateRefWrapper(this.templateRefs.last, templateFunc);
50+
return new TemplateRefWrapper(this.templateRefs.last, templateFunc, this.embeddedViewDestroyCallback);
3951
}
4052

4153
public getTemplateFunction(templateRef: TemplateRefWrapper<any>): TemplateFunction | undefined {
@@ -45,4 +57,19 @@ export class TemplateWrapperComponent {
4557
const index = this.templateRefs.toArray().indexOf(templateRef.innerTemplateRef);
4658
return this.templateFunctions[index - 1];
4759
}
60+
61+
/**
62+
* Set connectivity on RootPart produced by {@link https://lit.dev/docs/api/templates/#render render()}
63+
* when Angular destroys the owning EmbeddedView.
64+
*
65+
* Required for any `AsyncDirective` in the template result, as those rely on the part to trigger
66+
* the lifecycle hooks like `disconnected`, and is the "responsibility of the caller to `render`".
67+
* @see {@link https://lit.dev/docs/api/misc/#RootPart} Details section.
68+
*/
69+
protected embeddedViewDestroyCallback = (container: HTMLElement) => {
70+
if (container && this.childParts.has(container)) {
71+
this.childParts.get(container).setConnected(false);
72+
this.childParts.delete(container);
73+
}
74+
}
4875
}

0 commit comments

Comments
 (0)