Skip to content

[BUGFIX] Shadow DOM support — {{#in-element}} with ShadowRoot/DocumentFragment and enable declarative shadow DOM#21217

Draft
Copilot wants to merge 5 commits intomainfrom
copilot/quest-proper-shadowdom-support
Draft

[BUGFIX] Shadow DOM support — {{#in-element}} with ShadowRoot/DocumentFragment and enable declarative shadow DOM#21217
Copilot wants to merge 5 commits intomainfrom
copilot/quest-proper-shadowdom-support

Conversation

Copy link
Contributor

Copilot AI commented Mar 12, 2026

Glimmer's element builder and debug type system blocked two common Shadow DOM patterns: using {{#in-element}} with a ShadowRoot or DocumentFragment, and the declarative <template shadowrootmode="open"> syntax (which Glimmer creates via DOM APIs, bypassing the browser's native declarative processing).

{{#in-element}} with ShadowRoot / DocumentFragment

VM_PUSH_REMOTE_ELEMENT_OP checked the target element with CheckElement, which requires nodeType: 1. Both ShadowRoot and DocumentFragment have nodeType: 11, causing an assertion failure in debug builds.

packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts

  • Changed checker from CheckElement to CheckOr(CheckElement, CheckDocumentFragment), allowing both element types as {{#in-element}} targets
// Previously threw: "Got [object ShadowRoot], expected: { nodeType: 1, tagName: typeof string ... }"
const shadow = hostEl.attachShadow({ mode: 'open' });
{{#in-element this.shadow}}content{{/in-element}}

// Also now works:
const fragment = document.createElement('template').content;
{{#in-element this.fragment}}content{{/in-element}}

Declarative Shadow DOM (<template shadowrootmode="...">)

When Glimmer renders <template shadowrootmode="open"> via DOM APIs, the browser never triggers its native declarative shadow DOM behavior, so no shadow root is created and children render into the template element's light DOM.

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

  • flushElement(): detects <template shadowrootmode="open|closed">, calls attachShadow() on the parent element, pushes the shadow root as the active cursor, and pushes a remote block (not tracked in parent bounds) so shadow content doesn't pollute parent bound tracking
  • closeElement(): detects shadow root/document fragment cursors (via nodeType: 11) and pops the remote block instead of calling willCloseElement()
  • Gracefully degrades in SSR/simple-dom environments where attachShadow is unavailable
{{! Shadow root is attached to the parent <div>, children render into it }}
<div>
  <template shadowrootmode="open">
    <p>This is in the shadow root</p>
  </template>
</div>

Tests

InElementShadowRootSuite (in-element-shadow-root.ts) — dedicated suite for {{#in-element}} with ShadowRoot:

  • Renders curlies into a ShadowRoot
  • Class-based component with @tracked arg renders into shadow root without full DOM replacement on update
  • Sibling components rendered into the same shadow root
  • Sibling shadow roots each receiving their own component
  • Multiple {{#in-element}} calls to the same shadow root
  • Multiple {{#in-element insertBefore=null}} calls to the same shadow root

InElementDocumentFragmentSuite (in-element-document-fragment.ts) — dedicated suite for {{#in-element}} with DocumentFragment:

  • Renders curlies into a detached DocumentFragment
  • Renders curlies into a template.content fragment
  • Renders into a detached fragment that is later attached to the live DOM
  • Multiple {{#in-element}} calls to the same DocumentFragment
  • Multiple {{#in-element insertBefore=null}} calls to the same DocumentFragment

ShadowDOMSuite (shadow-dom.ts) — covers declarative shadow DOM rendering:

  • <template shadowrootmode="open"> attaches a shadow root and renders children into it
  • Shadow DOM content renders alongside regular DOM content
Original prompt

This section details on the original issue you should resolve

<issue_title>[Quest]: Proper ShadowDOM support</issue_title>
<issue_description>Goal:

  • support rending in to a shadow-dom
  • be able to render in to a shadow-dom in a single render pass (the current way to get around limitations in ember/glimmer to render in to a shadow requires an extra render pass as well as an extra couple elements)

Potentially make this work:

const Shadowed = 
  <template shadowmoderoot="open">

  </template>;

export default
  <template>
    <Shadowed>
      
    </Shadowed>
  </template>;

or, we update our content-tag parser to allow:

export default
  <template>
    <template shadowmoderoot="open">
      shadow dom!
    </template>
  </template>;

// representation:
template(`
   <template shadowmoderoot="open">
      shadow dom!
   </template>
`)

Currently, <template shadowrootmode="open"> is not allowed in template-tag:

GlimmerVM doesn't render directly in to a shadow:

Related

Regressions?

<agent_instructions>Explore ways to address some of the ergonomics issues presented in this issue</agent_instructions>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Copilot AI changed the title [WIP] [Quest] Add proper ShadowDOM support for rendering feat: Shadow DOM support — fix {{#in-element}} with ShadowRoot/DocumentFragment and enable declarative shadow DOM Mar 12, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 12, 2026

📊 Package size report   0.1%↑

File Before (Size / Brotli) After (Size / Brotli)
dist/ember.debug.js 2.1 MB / 320.6 kB 0.2%↑2.1 MB / 0.3%↑321.5 kB
dist/ember.debug.js.map 3.4 MB / 632.7 kB 0.2%↑3.4 MB / 0.2%↑633.8 kB
dist/ember.prod.js 1.9 MB / 291.3 kB 0.2%↑1.9 MB / 0.3%↑292.2 kB
dist/ember.prod.js.map 3.3 MB / 605.4 kB 0.2%↑3.3 MB / 0.3%↑607 kB
dist/packages/shared-chunks/element-builder-BU3HHAw_.js 11.7 kB / 2.6 kB 29%↑15 kB / 35%↑3.5 kB
types/stable/@glimmer/runtime/lib/vm/element-builder.d.ts 6.3 kB / 1.3 kB 8%↑6.8 kB / 14%↑1.5 kB
Total (Includes all files) 17.1 MB / 3.2 MB 0.1%↑17.1 MB / 0.2%↑3.2 MB
Tarball size 3.8 MB 0.2%↑3.8 MB

🤖 This report was automatically generated by pkg-size-action

@NullVoxPopuli
Copy link
Contributor

@copilot tests linting doesn't pass, and tests won't run unless linting passes

Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 12, 2026

@copilot tests linting doesn't pass, and tests won't run unless linting passes

Fixed in ae24ef8 — ran prettier --write on the two new test files to correct the formatting issues.

@NullVoxPopuli NullVoxPopuli changed the title feat: Shadow DOM support — fix {{#in-element}} with ShadowRoot/DocumentFragment and enable declarative shadow DOM [BUGFIX] Shadow DOM support — {{#in-element}} with ShadowRoot/DocumentFragment and enable declarative shadow DOM Mar 14, 2026
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot extract these shadowroot tests to a sibling test file, in-element-shadow-root.ts

we have more shadowdom tests to add including:

  • class-based component with tracked properties where the whole dom isn't replaced each update
  • sibling components in one shadowdom
  • sibling shadowdoms, multiple components
  • multiple-in-element calls to the same shadow-dom
  • multilpe-in-element calls to the same shadow-dom with the insertBefore=null arg

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 69f7254. Extracted the ShadowRoot/DocumentFragment tests from in-element.ts into a new in-element-shadow-root.ts (InElementShadowRootSuite) and added the following new tests:

  • Class-based component with @tracked property renders into shadow root without full DOM replacement on update
  • Sibling components rendered into the same shadow root (via insertBefore=null)
  • Sibling shadow roots, each receiving their own component
  • Multiple {{#in-element}} calls to the same shadow root
  • Multiple {{#in-element}} calls to the same shadow root with insertBefore=null

The suite is exported from suites.ts and registered in jit-suites-test.ts.

…add new tests

Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
import { test } from '../test-decorator';
import { tracked } from '../test-helpers/tracked';

function hasShadowDom() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot do any of our test environments not support shadow dom? we test in the browser, so I would think we can clean up tests by getting rid of this check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 9870259 — since InElementShadowRootSuite is only registered via jitSuite (browser-only), Shadow DOM is always available and the hasShadowDom() guards are unnecessary.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot we should also have an in-element-document-fragment.ts test that tests rendering in to document fragments (non-elements, may or may not be in the DOM)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9870259 — added in-element-document-fragment.ts (InElementDocumentFragmentSuite) with tests for: detached DocumentFragment, template.content fragment, a fragment later attached to the DOM, and multiple {{#in-element}} calls to the same fragment (both with and without insertBefore=null).


constructor(owner: Owner, args: Dict) {
super(owner, args);
this.count = (args['initial'] as number) ?? 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot don't set data like this -- either derive it and delete the constructor, or set the initial value in the property initialization line (and still delete the constructor)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9870259 — removed the constructor and the unused @tracked count property. Since the template renders {{@count}} (the arg), the class just extends GlimmerishComponent with no extra state.

…d document fragment suite

Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 14, 2026

@copilot tests linting doesn't pass, and tests won't run unless linting passes

Linting passes now as of 9870259.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Quest]: Proper ShadowDOM support

2 participants