Skip to content

Commit 90b516c

Browse files
committed
fix(elements): allow custom elements to be detached and reattached
Allows Angular elements to remains detached from DOM and reattached later, without breaking it. Fixes angular#38778
1 parent 8a1e36b commit 90b516c

3 files changed

Lines changed: 242 additions & 1 deletion

File tree

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: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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, platformBrowser} from '@angular/platform-browser';
18+
import {createCustomElement} from '../src/create-custom-element';
19+
20+
const tick = (ms: number) => {
21+
return new Promise((resolve) => setTimeout(resolve, ms));
22+
};
23+
24+
describe('Reconnect', () => {
25+
let testContainer: HTMLDivElement;
26+
27+
beforeAll((done) => {
28+
testContainer = document.createElement('div');
29+
document.body.appendChild(testContainer);
30+
destroyPlatform();
31+
platformBrowser()
32+
.bootstrapModule(TestModule)
33+
.then((ref) => {
34+
const injector = ref.injector;
35+
const cfr: ComponentFactoryResolver = injector.get(ComponentFactoryResolver);
36+
37+
testElements.forEach((comp) => {
38+
const compFactory = cfr.resolveComponentFactory(comp);
39+
customElements.define(compFactory.selector, createCustomElement(comp, {injector}));
40+
});
41+
})
42+
.then(done, done.fail);
43+
});
44+
45+
afterAll(() => {
46+
destroyPlatform();
47+
testContainer.remove();
48+
(testContainer as any) = null;
49+
});
50+
51+
it('should be able to rebuild and reconnect after direct disconnection from parent', async () => {
52+
// Create and attach it
53+
const tpl = `<reconnect-el test-attr="a"></reconnect-el>`;
54+
testContainer.innerHTML = tpl;
55+
// Check that the Angular element was created and attributes are bound
56+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
57+
// Check that the Angular element was bound to properties too
58+
const testEl = testContainer.querySelector<Element & ReconnectTestComponentEl>('reconnect-el')!;
59+
testEl.testProp = 'b';
60+
// Wait change to be propagated
61+
await tick(10);
62+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
63+
// Now detach the element from the container
64+
testContainer.removeChild(testEl);
65+
// Wait for detach timer
66+
await tick(10);
67+
// Check that the web-element is orphan and the Angular Component is destroyed
68+
expect(testEl.parentElement).toBeFalsy();
69+
// Check property values to be maintained
70+
expect(testEl.testProp).toBe('b');
71+
72+
// Now reattach root to testContainer
73+
testContainer.appendChild(testEl);
74+
// Check for re-render, but with the same instance of web-element
75+
expect(
76+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('reconnect-el').length,
77+
).toBe(1);
78+
expect(
79+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('.reconnect-el').length,
80+
).toBe(1);
81+
expect(testContainer.querySelectorAll('.test-attr-outlet').length).toBe(1);
82+
expect(testContainer.querySelectorAll('.test-prop-outlet').length).toBe(1);
83+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
84+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
85+
});
86+
87+
it('should be able to rebuild and reconnect after indirect disconnection via parent node', async () => {
88+
const tpl = `<div class="root"><reconnect-el test-attr="a"></reconnect-el></div>`;
89+
testContainer.innerHTML = tpl;
90+
const root = testContainer.querySelector<HTMLDivElement>('.root')!;
91+
// Check that the Angular element was created and attributes are bound
92+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
93+
// Check that the Angular element was bound to properties too
94+
const testEl = testContainer.querySelector<Element & ReconnectTestComponentEl>('reconnect-el')!;
95+
testEl.testProp = 'b';
96+
// Wait change to be propagated
97+
await tick(10);
98+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
99+
100+
// Now detach the root from the DOM
101+
testContainer.removeChild(root);
102+
// Wait for detach timer
103+
await tick(10);
104+
// Check that the web-element is still under root, but the Angular Component is destroyed
105+
expect(testEl.parentElement).toBe(root);
106+
// Check property values to be maintained
107+
expect(testEl.testProp).toBe('b');
108+
109+
// Now reattach root to testContainer
110+
testContainer.appendChild(root);
111+
// Check for re-render, but with the same instance of web-element
112+
expect(testContainer.querySelector<Element & ReconnectTestComponentEl>('reconnect-el')).toBe(
113+
testEl,
114+
);
115+
expect(
116+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('reconnect-el').length,
117+
).toBe(1);
118+
expect(
119+
testContainer.querySelectorAll<Element & ReconnectTestComponentEl>('.reconnect-el').length,
120+
).toBe(1);
121+
expect(testContainer.querySelectorAll('.test-attr-outlet').length).toBe(1);
122+
expect(testContainer.querySelectorAll('.test-prop-outlet').length).toBe(1);
123+
expect(testContainer.querySelector('.test-attr-outlet')!.textContent).toBe('a');
124+
expect(testContainer.querySelector('.test-prop-outlet')!.textContent).toBe('b');
125+
});
126+
127+
it('should be able to rebuild and reconnect after indirect disconnection via parent node, with slots', async () => {
128+
const tpl = `<div class="root"><reconnect-slotted-el><span class="projected"></span></reconnect-slotted-el></div>`;
129+
testContainer.innerHTML = tpl;
130+
const root = testContainer.querySelector<HTMLDivElement>('.root')!;
131+
const testEl = testContainer.querySelector('reconnect-slotted-el')!;
132+
133+
// Check that the Angular element was created and slots are projected
134+
{
135+
const content = testContainer.querySelector('span.projected')!;
136+
const slot = testEl.shadowRoot!.querySelector('slot') as HTMLSlotElement;
137+
const assignedNodes = slot.assignedNodes();
138+
expect(assignedNodes[0]).toBe(content);
139+
}
140+
141+
// Now detach the root from the DOM
142+
testContainer.removeChild(root);
143+
// Wait for detach timer
144+
await tick(10);
145+
146+
// Check that the web-element is still under root, but the Angular Component is destroyed
147+
expect(testEl.parentElement).toBe(root);
148+
149+
// Now reattach root to testContainer
150+
testContainer.appendChild(root);
151+
// Check for re-render, but with the same instance of web-element
152+
expect(testContainer.querySelectorAll('reconnect-slotted-el').length).toBe(1);
153+
expect(testEl.shadowRoot!.querySelectorAll('.reconnect-slotted-el').length).toBe(1);
154+
155+
// Check that the Angular element was re-created and slots are still projected
156+
{
157+
const content = testContainer.querySelector('span.projected')!;
158+
const slot = testEl.shadowRoot!.querySelector('slot') as HTMLSlotElement;
159+
const assignedNodes = slot.assignedNodes();
160+
expect(assignedNodes[0]).toBe(content);
161+
}
162+
});
163+
});
164+
165+
interface ReconnectTestComponentEl {
166+
testProp: string;
167+
}
168+
169+
@Component({
170+
selector: 'reconnect-el',
171+
template:
172+
'<div class="reconnect-el"><p class="test-prop-outlet">{{testProp}}</p><p class="test-attr-outlet">{{testAttr}}</p></div>',
173+
standalone: false,
174+
})
175+
class ReconnectTestComponent implements ReconnectTestComponentEl {
176+
@Input() testAttr: string = '';
177+
@Input() testProp: string = '';
178+
constructor() {}
179+
}
180+
181+
@Component({
182+
selector: 'reconnect-slotted-el',
183+
template: '<div class="reconnect-slotted-el"><slot></slot></div>',
184+
encapsulation: ViewEncapsulation.ShadowDom,
185+
standalone: false,
186+
})
187+
class ReconnectSlottedTestComponent {
188+
constructor() {}
189+
}
190+
191+
const testElements = [ReconnectTestComponent, ReconnectSlottedTestComponent];
192+
193+
@NgModule({imports: [BrowserModule], declarations: testElements})
194+
class TestModule {
195+
ngDoBootstrap() {}
196+
}

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)