Skip to content

Commit 81a7992

Browse files
fix: reuse existing open shadow root on re-render when component root is declarative shadow DOM template
Co-authored-by: NullVoxPopuli <[email protected]>
1 parent 9870259 commit 81a7992

2 files changed

Lines changed: 151 additions & 1 deletion

File tree

packages/@glimmer-workspace/integration-tests/lib/suites/shadow-dom.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { castToBrowser } from '@glimmer/debug-util';
22

3+
import { GlimmerishComponent } from '../components/emberish-glimmer';
34
import { RenderTest } from '../render-test';
45
import { test } from '../test-decorator';
6+
import { tracked } from '../test-helpers/tracked';
57

68
export class ShadowDOMSuite extends RenderTest {
79
static suiteName = 'Shadow DOM';
@@ -75,4 +77,137 @@ export class ShadowDOMSuite extends RenderTest {
7577
'<template> element is not in the regular DOM'
7678
);
7779
}
80+
81+
@test
82+
'<template shadowrootmode="open"> as component root renders into the parent element shadow root'() {
83+
if (typeof document === 'undefined' || !('attachShadow' in document.createElement('div'))) {
84+
this.assert.ok(true, 'Shadow DOM not supported, skipping');
85+
return;
86+
}
87+
88+
this.registerComponent(
89+
'TemplateOnly',
90+
'ShadowComp',
91+
'<template shadowrootmode="open"><p>{{@message}}</p></template>'
92+
);
93+
94+
// When the component root is <template shadowrootmode="open">, the shadow root is
95+
// attached to the element that immediately contains the component (in this case, the
96+
// <div class="host">).
97+
this.render('<div class="host"><ShadowComp @message={{this.msg}} /></div>', {
98+
msg: 'initial',
99+
});
100+
101+
const rootEl = castToBrowser(this.element, 'HTML');
102+
const host = rootEl.querySelector('.host') as HTMLElement | null;
103+
104+
this.assert.ok(host !== null, 'host element exists');
105+
this.assert.ok(host?.shadowRoot !== null, 'shadow root is attached to the host element');
106+
this.assert.strictEqual(
107+
host?.shadowRoot?.querySelector('p')?.textContent,
108+
'initial',
109+
'initial content rendered into shadow root'
110+
);
111+
112+
this.assertStableRerender();
113+
114+
// Rerender with new arg — shadow root content should update
115+
this.rerender({ msg: 'updated' });
116+
this.assert.strictEqual(
117+
host?.shadowRoot?.querySelector('p')?.textContent,
118+
'updated',
119+
'shadow root content updated after rerender'
120+
);
121+
}
122+
123+
@test
124+
'<template shadowrootmode="open"> as component root re-renders correctly after full component recreation'() {
125+
if (typeof document === 'undefined' || !('attachShadow' in document.createElement('div'))) {
126+
this.assert.ok(true, 'Shadow DOM not supported, skipping');
127+
return;
128+
}
129+
130+
this.registerComponent(
131+
'TemplateOnly',
132+
'ShadowComp',
133+
'<template shadowrootmode="open"><span>{{@label}}</span></template>'
134+
);
135+
136+
// Wrap in a conditional so we can force component destruction + recreation
137+
this.render(
138+
'{{#if this.show}}<div class="host"><ShadowComp @label={{this.label}} /></div>{{/if}}',
139+
{ show: true, label: 'first' }
140+
);
141+
142+
const rootEl = castToBrowser(this.element, 'HTML');
143+
const getHost = () => rootEl.querySelector('.host') as HTMLElement | null;
144+
145+
this.assert.ok(getHost()?.shadowRoot !== null, 'shadow root attached on first render');
146+
this.assert.strictEqual(
147+
getHost()?.shadowRoot?.querySelector('span')?.textContent,
148+
'first',
149+
'first render content correct'
150+
);
151+
152+
// Remove the component from the DOM
153+
this.rerender({ show: false, label: 'first' });
154+
this.assert.strictEqual(getHost(), null, 'host element removed from DOM');
155+
156+
// Re-insert the component — the shadow root is on a fresh <div class="host">,
157+
// so attachShadow should succeed on the new element
158+
this.rerender({ show: true, label: 'second' });
159+
this.assert.ok(getHost()?.shadowRoot !== null, 'shadow root attached on second render');
160+
this.assert.strictEqual(
161+
getHost()?.shadowRoot?.querySelector('span')?.textContent,
162+
'second',
163+
're-rendered content correct in shadow root'
164+
);
165+
}
166+
167+
@test
168+
'<template shadowrootmode="open"> as component root with tracked state re-renders into same shadow root'() {
169+
if (typeof document === 'undefined' || !('attachShadow' in document.createElement('div'))) {
170+
this.assert.ok(true, 'Shadow DOM not supported, skipping');
171+
return;
172+
}
173+
174+
class Counter extends GlimmerishComponent {
175+
@tracked count = 0;
176+
}
177+
178+
this.registerComponent(
179+
'Glimmer',
180+
'Counter',
181+
'<template shadowrootmode="open"><p>{{@count}}</p></template>',
182+
Counter as any
183+
);
184+
185+
// Render the counter component with a wrapping div
186+
this.render('<div class="host"><Counter @count={{this.count}} /></div>', { count: 0 });
187+
188+
const rootEl = castToBrowser(this.element, 'HTML');
189+
const host = rootEl.querySelector('.host') as HTMLElement | null;
190+
191+
this.assert.ok(host?.shadowRoot !== null, 'shadow root attached');
192+
this.assert.strictEqual(
193+
host?.shadowRoot?.querySelector('p')?.textContent,
194+
'0',
195+
'initial count renders in shadow root'
196+
);
197+
198+
// Update tracked state — shadow root should be reused (not recreated)
199+
const shadowRootRef = host?.shadowRoot;
200+
this.rerender({ count: 1 });
201+
202+
this.assert.strictEqual(
203+
host?.shadowRoot?.querySelector('p')?.textContent,
204+
'1',
205+
'count updated in shadow root'
206+
);
207+
this.assert.strictEqual(
208+
host?.shadowRoot,
209+
shadowRootRef,
210+
'same shadow root instance reused (not recreated)'
211+
);
212+
}
78213
}

packages/@glimmer/runtime/lib/vm/element-builder.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ export class NewTreeBuilder implements TreeBuilder {
251251
*
252252
* This handles the `<template shadowrootmode="open|closed">` pattern, enabling
253253
* Glimmer to render directly into a shadow root without an extra render pass.
254+
*
255+
* When `<template shadowrootmode="open|closed">` is used as a component's root element
256+
* (without a wrapping element), the same parent element receives the shadow root on
257+
* every render pass. On re-render, `attachShadow` would throw because the shadow root
258+
* already exists. In that case we reuse the existing open shadow root after clearing
259+
* its children, so the component can re-render into it correctly.
254260
*/
255261
protected __tryAttachShadowRoot(
256262
parent: SimpleElement,
@@ -267,10 +273,19 @@ export class NewTreeBuilder implements TreeBuilder {
267273
const rawParent = parent as unknown as Element;
268274
if (typeof rawParent.attachShadow !== 'function') return null;
269275

276+
// If parent already has an open shadow root (e.g. on re-render when the component's
277+
// root is <template shadowrootmode="open"> without a wrapping element), reuse it after
278+
// clearing its children rather than calling attachShadow again (which would throw).
279+
const existingShadowRoot = rawParent.shadowRoot;
280+
if (existingShadowRoot) {
281+
existingShadowRoot.replaceChildren();
282+
return existingShadowRoot as unknown as SimpleElement;
283+
}
284+
270285
try {
271286
return rawParent.attachShadow({ mode: mode as ShadowRootMode }) as unknown as SimpleElement;
272287
} catch {
273-
// attachShadow can fail if the element already has a shadow root
288+
// attachShadow can fail if the element already has a closed shadow root
274289
// or doesn't support shadow DOM. Fall back to normal rendering.
275290
return null;
276291
}

0 commit comments

Comments
 (0)