Skip to content

Commit 8bc2d7e

Browse files
committed
fix(elements): allow custom elements to be detached and reattached
Fixes angular#38778
1 parent 8a1e36b commit 8bc2d7e

File tree

3 files changed

+241
-1
lines changed

3 files changed

+241
-1
lines changed

packages/elements/src/component-factory-strategy.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ComponentFactory,
1515
ComponentFactoryResolver,
1616
ComponentRef,
17+
EmbeddedViewRef,
1718
EventEmitter,
1819
Injector,
1920
NgZone,
@@ -143,14 +144,51 @@ export class ComponentNgElementStrategy implements NgElementStrategy {
143144
// Schedule the component to be destroyed after a small timeout in case it is being
144145
// moved elsewhere in the DOM
145146
this.scheduledDestroyFn = scheduler.schedule(() => {
147+
this.scheduledDestroyFn = null;
146148
if (this.componentRef !== null) {
149+
// Save old position of the element node, to maintain it attached even
150+
// after the this.componentRef.destroy, that detach it from parent
151+
const attachInfo = this.getElementAttachPoint();
152+
// Prepare back properties from componentRef to `initialInputValues`
153+
this.resetProperties();
147154
this.componentRef.destroy();
148155
this.componentRef = null;
156+
// Reattach the destroyed empty html element to the parent
157+
if (attachInfo) {
158+
const {parent, viewNode, beforeOf} = attachInfo;
159+
parent.insertBefore(viewNode, beforeOf);
160+
}
149161
}
150162
}, DESTROY_DELAY);
151163
});
152164
}
153165

166+
/**
167+
* Get the current attach point of the custom element and his position
168+
*/
169+
private getElementAttachPoint():
170+
| {parent: HTMLElement; viewNode: HTMLElement; beforeOf: ChildNode}
171+
| undefined {
172+
const hostView = this.componentRef!.hostView as EmbeddedViewRef<unknown>;
173+
const viewNode = hostView?.rootNodes?.[0] as HTMLElement;
174+
const parent = viewNode?.parentElement!;
175+
if (!parent) {
176+
return;
177+
}
178+
const index = parent && Array.from(parent.childNodes).indexOf(viewNode);
179+
const beforeOf = parent && parent.childNodes.item(index + 1);
180+
return {parent, viewNode, beforeOf};
181+
}
182+
183+
/**
184+
* Copy back live properties from the componentRef (about to be destroyed) to initialInputValues for future reattach
185+
*/
186+
private resetProperties() {
187+
this.componentFactory.inputs.forEach((input) => {
188+
this.initialInputValues.set(input.propName, this.componentRef!.instance[input.propName]);
189+
});
190+
}
191+
154192
/**
155193
* Returns the component property value. If the component has not yet been created, the value is
156194
* retrieved from the cached initialization values.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
Component,
11+
ComponentFactoryResolver,
12+
destroyPlatform,
13+
Input,
14+
NgModule,
15+
ViewEncapsulation,
16+
} from '@angular/core';
17+
import {BrowserModule} from '@angular/platform-browser';
18+
import {platformBrowser} from '@angular/platform-browser';
19+
20+
import {createCustomElement} from '../src/create-custom-element';
21+
22+
const tick = (ms: number) => {
23+
return new Promise((resolve) => setTimeout(resolve, ms));
24+
};
25+
26+
describe('Reconnect', () => {
27+
let testContainer: HTMLDivElement;
28+
29+
beforeAll((done) => {
30+
testContainer = document.createElement('div');
31+
document.body.appendChild(testContainer);
32+
destroyPlatform();
33+
platformBrowser()
34+
.bootstrapModule(TestModule)
35+
.then((ref) => {
36+
const injector = ref.injector;
37+
const cfr: ComponentFactoryResolver = injector.get(ComponentFactoryResolver);
38+
39+
testElements.forEach((comp) => {
40+
const compFactory = cfr.resolveComponentFactory(comp);
41+
customElements.define(compFactory.selector, createCustomElement(comp, {injector}));
42+
});
43+
})
44+
.then(done, done.fail);
45+
});
46+
47+
afterAll(() => {
48+
destroyPlatform();
49+
testContainer.remove();
50+
(testContainer as any) = null;
51+
});
52+
53+
it('should be able to rebuild and reconnect after direct disconnection from parent', async () => {
54+
// Create and attach it
55+
const tpl = `<reconnect-el test-attr="a"></reconnect-el>`;
56+
testContainer.innerHTML = tpl;
57+
// Check that the Angular element was created and attributes are bound
58+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
59+
// Check that the Angular element was bound to properties too
60+
const testEl = testContainer.querySelector<Element & ReconnectTestComponentEl>('reconnect-el')!;
61+
testEl.testProp = 'b';
62+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
63+
64+
// Now detach the element from the container
65+
testContainer.removeChild(testEl);
66+
// Wait for detach timer
67+
await tick(10);
68+
// Check that the web-element is orphan and the Angular Component is destroyed
69+
expect(testEl.parentElement).toBeFalsy();
70+
// Check property values to be maintained
71+
expect(testEl.testProp).toBe('b');
72+
73+
// Now reattach root to testContainer
74+
testContainer.appendChild(testEl);
75+
// Check for re-render, but with the same instance of web-element
76+
expect(
77+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('reconnect-el').length,
78+
).toBe(1);
79+
expect(
80+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('.reconnect-el').length,
81+
).toBe(1);
82+
expect(testContainer.querySelectorAll('.test-attr-outlet').length).toBe(1);
83+
expect(testContainer.querySelectorAll('.test-prop-outlet').length).toBe(1);
84+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
85+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
86+
});
87+
88+
it('should be able to rebuild and reconnect after indirect disconnection via parent node', async () => {
89+
const tpl = `<div class="root"><reconnect-el test-attr="a"></reconnect-el></div>`;
90+
testContainer.innerHTML = tpl;
91+
const root = testContainer.querySelector<HTMLDivElement>('.root')!;
92+
// Check that the Angular element was created and attributes are bound
93+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
94+
// Check that the Angular element was bound to properties too
95+
const testEl = testContainer.querySelector<Element & ReconnectTestComponentEl>('reconnect-el')!;
96+
testEl.testProp = 'b';
97+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
98+
99+
// Now detach the root from the DOM
100+
testContainer.removeChild(root);
101+
// Wait for detach timer
102+
await tick(10);
103+
// Check that the web-element is still under root, but the Angular Component is destroyed
104+
expect(testEl.parentElement).toBe(root);
105+
// Check property values to be maintained
106+
expect(testEl.testProp).toBe('b');
107+
108+
// Now reattach root to testContainer
109+
testContainer.appendChild(root);
110+
// Check for re-render, but with the same instance of web-element
111+
expect(testContainer.querySelector<Element & ReconnectTestComponentEl>('reconnect-el')).toBe(
112+
testEl,
113+
);
114+
expect(
115+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('reconnect-el').length,
116+
).toBe(1);
117+
expect(
118+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('.reconnect-el').length,
119+
).toBe(1);
120+
expect(testContainer.querySelectorAll('.test-attr-outlet').length).toBe(1);
121+
expect(testContainer.querySelectorAll('.test-prop-outlet').length).toBe(1);
122+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
123+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
124+
});
125+
126+
it('should be able to rebuild and reconnect after indirect disconnection via parent node, with slots', async () => {
127+
const tpl = `<div class="root"><reconnect-slotted-el><span class="projected"></span></reconnect-slotted-el></div>`;
128+
testContainer.innerHTML = tpl;
129+
const root = testContainer.querySelector<HTMLDivElement>('.root')!;
130+
const testEl = testContainer.querySelector('reconnect-slotted-el')!;
131+
132+
// Check that the Angular element was created and slots are projected
133+
{
134+
const content = testContainer.querySelector('span.projected')!;
135+
const slot = testEl.shadowRoot!.querySelector('slot') as HTMLSlotElement;
136+
const assignedNodes = slot.assignedNodes();
137+
expect(assignedNodes[0]).toBe(content);
138+
}
139+
140+
// Now detach the root from the DOM
141+
testContainer.removeChild(root);
142+
// Wait for detach timer
143+
await tick(10);
144+
145+
// Check that the web-element is still under root, but the Angular Component is destroyed
146+
expect(testEl.parentElement).toBe(root);
147+
148+
// Now reattach root to testContainer
149+
testContainer.appendChild(root);
150+
// Check for re-render, but with the same instance of web-element
151+
expect(testContainer.querySelectorAll('reconnect-slotted-el').length).toBe(1);
152+
expect(testEl.shadowRoot!.querySelectorAll('.reconnect-slotted-el').length).toBe(1);
153+
154+
// Check that the Angular element was re-created and slots are still projected
155+
{
156+
const content = testContainer.querySelector('span.projected')!;
157+
const slot = testEl.shadowRoot!.querySelector('slot') as HTMLSlotElement;
158+
const assignedNodes = slot.assignedNodes();
159+
expect(assignedNodes[0]).toBe(content);
160+
}
161+
});
162+
});
163+
164+
interface ReconnectTestComponentEl {
165+
testProp: string;
166+
}
167+
168+
@Component({
169+
selector: 'reconnect-el',
170+
template:
171+
'<div class="reconnect-el"><p class="test-prop-outlet">{{testProp}}</p><p class="test-attr-outlet">{{testAttr}}</p></div>',
172+
standalone: false,
173+
})
174+
class ReconnectTestComponent implements ReconnectTestComponentEl {
175+
@Input() testAttr: string = '';
176+
@Input() testProp: string = '';
177+
constructor() {}
178+
}
179+
180+
@Component({
181+
selector: 'reconnect-slotted-el',
182+
template: '<div class="reconnect-slotted-el"><slot></slot></div>',
183+
encapsulation: ViewEncapsulation.ShadowDom,
184+
standalone: false,
185+
})
186+
class ReconnectSlottedTestComponent {
187+
constructor() {}
188+
}
189+
190+
const testElements = [ReconnectTestComponent, ReconnectSlottedTestComponent];
191+
192+
@NgModule({imports: [BrowserModule], declarations: testElements})
193+
class TestModule {
194+
ngDoBootstrap() {}
195+
}

packages/platform-browser/src/dom/dom_renderer.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,14 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
522522
private sharedStylesHost?: SharedStylesHost,
523523
) {
524524
super(eventManager, doc, ngZone, platformIsServer, tracingService);
525-
this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'});
525+
this.shadowRoot = (hostEl as Element).shadowRoot;
526+
if (!this.shadowRoot) {
527+
this.shadowRoot = (hostEl as Element).attachShadow({mode: 'open'});
528+
} else {
529+
// In case of custom elements, it is possible that the host element was already initialized during the
530+
// element life-cycle (after a disconnect/reconnect)
531+
this.shadowRoot.innerHTML = '';
532+
}
526533

527534
// SharedStylesHost is used to add styles to the shadow root by ShadowDom.
528535
// This is optional as it is not used by IsolatedShadowDom.

0 commit comments

Comments
 (0)