Skip to content

[BUGFIX] DocumentFragment support — {{#in-element}} with DocumentFragment targets#21253

Draft
Copilot wants to merge 10 commits intomainfrom
copilot/extract-document-fragment-support
Draft

[BUGFIX] DocumentFragment support — {{#in-element}} with DocumentFragment targets#21253
Copilot wants to merge 10 commits intomainfrom
copilot/extract-document-fragment-support

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 26, 2026

Adds full DocumentFragment support to {{#in-element}}, including correct behaviour after the fragment's children have been moved into a real DOM container via appendChild().

Runtime Fixes (@glimmer/runtime)

  • bounds.tsclear(): Use firstNode().parentNode (the live current parent) instead of the stored parentElement(). After a DocumentFragment is appended to the DOM, the rendered nodes' actual parent is the container, not the now-empty fragment.
  • element-builder.tsNewTreeBuilder.resume(): Capture the live parent from firstNode().parentNode before resetting the block, so subsequent renders are inserted into the real container rather than the detached fragment.
  • element-builder.tsRemoteBlock destructor: Changed the guard from parentElement() === firstNode().parentNode (always false after fragment attachment, silently skipping cleanup) to firstNode().parentNode !== null (node is still attached to some parent), so content is correctly cleaned up on destroy in both the normal and DocumentFragment cases.

Test

Added a test (InElementDocumentFragmentSuite) that:

  1. Renders reactive content into a detached DocumentFragment via {{#in-element}}.
  2. Moves the fragment's children into a real DOM container with container.appendChild(fragment).
  3. Calls rerender and verifies that text-node updates and new conditional elements both appear correctly in the container — using assert.step called from within the template and assert.verifySteps to document each reactive update.

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Agent-Logs-Url: https://github.com/emberjs/ember.js/sessions/b223b6e1-6551-4db1-bb11-99dac1313bec

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

github-actions bot commented Mar 26, 2026

📊 Package size report   0.04%↑

File Before (Size / Brotli) After (Size / Brotli)
dist/dev/packages/shared-chunks/dynamic-BVcQCbj2.js 116.1 kB / 23.6 kB 0.3%↑116.4 kB / 0.4%↑23.6 kB
dist/dev/packages/shared-chunks/element-builder-BLR_6MBx.js 11.7 kB / 2.6 kB 6%↑12.3 kB / 8%↑2.8 kB
dist/dev/packages/shared-chunks/index-BGP1rw3B.js 62.7 kB / 12.5 kB 213%↑196.1 kB / 244%↑43.1 kB
dist/dev/packages/shared-chunks/serialize-builder-DNnj6jIo.js 4 kB / 1.2 kB 4%↑4.1 kB / 0.09%↑1.2 kB
dist/prod/packages/shared-chunks/element-builder-BZ4Dm7Vx.js 22.9 kB / 5.1 kB 3%↑23.5 kB / 4%↑5.3 kB
dist/prod/packages/shared-chunks/on-cMNgZCqZ.js 96.7 kB / 19.6 kB 0.3%↑97 kB / 0.6%↑19.7 kB
dist/prod/packages/shared-chunks/serialize-builder-BaXoVv-F.js 3.8 kB / 1.1 kB 4%↑4 kB / 0.4%↑1.1 kB
Total (Includes all files) 5.3 MB / 1.3 MB 0.04%↑5.3 MB / 0.04%↑1.3 MB
Tarball size 1.2 MB 0.07%↑1.2 MB

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

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

I'm impressed that adding document-fragment support is such a small change it doesn't bust past our noise filter on the size comment (the map files do tho haha)

… appended to DOM

Agent-Logs-Url: https://github.com/emberjs/ember.js/sessions/aec4e27c-bdb9-4439-8efe-a010eef96826

Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
…sertions

Agent-Logs-Url: https://github.com/emberjs/ember.js/sessions/509a66ed-dd19-4b09-aadc-523e8e19e5f6

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

This comment was marked as resolved.

This comment was marked as resolved.

@NullVoxPopuli

This comment was marked as resolved.

…ng to DOM

Agent-Logs-Url: https://github.com/emberjs/ember.js/sessions/509016a2-4d3c-4fa7-9143-d386e636ec22

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

This comment was marked as resolved.

@NullVoxPopuli

This comment was marked as resolved.

- bounds.ts clear(): use first.parentNode (live parent) instead of stored
  parentElement(), so removal targets the real container after fragment append
- element-builder.ts resume(): capture live parent from firstNode().parentNode
  before resetting, so new content renders into the container not the fragment
- element-builder.ts RemoteBlock destructor: check firstNode().parentNode !== null
  (node still attached) instead of === parentElement() (node in original parent),
  so in-element/DocumentFragment targets get cleaned up correctly on destroy
- Update test to attach fragment first, then rerender and verify updates work

Agent-Logs-Url: https://github.com/emberjs/ember.js/sessions/6f1bf405-84ab-4bd5-b42d-f061fadcf4df

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

This comment was marked as resolved.

@NullVoxPopuli

This comment was marked as resolved.

Agent-Logs-Url: https://github.com/emberjs/ember.js/sessions/54039a52-25ed-4859-8971-19493ebbd060

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

This comment was marked as resolved.

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

NullVoxPopuli commented Mar 27, 2026

I made a GH workflow for more easily testing these out:
NullVoxPopuli/limber#2101

(including PR deploy)

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

Demo with some info: https://test-ember-source-copilot-ex.limber-glimdown.pages.dev/edit?c=JYWwDg9gTgLgBAYQuCA7Apq%2BAzKy4DkAAgOYA2oI6UA9AMbKQZYEDcAUKJLHAN5xo4AXzi58xdCABG1GiAgATYNmDU2nFD34woAQzoBrdAoDyUgFbo68EWJCEikmbSjp9MYADd09CGTJWHmgAzursDKjB8FG6MOhwALxwOvpGphaBABS87HBwDACuWABccAAMADS5cMCodKWZAJSJAHxwMegwAHSFWADUfVVCjRzs4SE4eiRUWIlwChB0BTPddK6x6AAii8uYMABiUytNo8GdACqg6BAFMJlNrXzVeRHBfuhdZBAkmbi603tGuwhBU4ABGMplEZjAA8cXAZA2LWqdAAFlYDMl0fkQu84GM8jDUVBkXldME4Lp2ugwLo9HE4AAFABKcAA7vEMMZkhA4CBdEZREc9hTXKgFNRdFIAtUYIpdABPUHYPwGClsm5kBRwLnaqnyJQqag8ylgMCYbUwbF-AGzOVY%2BKbEwAWVB1TZqOAaJxBS1%2BV0BTOcAA4hQQFQoCaSJ0cahsIHuVIFe1kNcMHB0GQg-zUP9aiQ4E7ncVqrxeDaVkIhAS%2BLwAMS1AC0mckeyF-0r1byeTiAA94LVKe3bTBnrWvgWAERNlsrOBSRQKydVsdlmJxHo3LArscwqS3OWoAR1CiGBJrmAbLq1OhVlo3mE0fcwQ%2Bk2s0GcBTvsR-wsCIuI2iAA&format=gjs

import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { trackedObject } from '@ember/reactive/collections';

const state = trackedObject({
  count: 0,
  inc: () => staet.count++,
});


const fragment = document.createDocumentFragment();

setTimeout(() => {
    console.log(fragment)
}, 100);

<template>
  check the console 

  <hr>
  as a separate PR we need to make fragments renderable
  today, folks would need a modifier to append the fragment to the DOM, 
  which could cause Glimmer to get confused by someone else managing DOM:
  {{fragment}}

  {{#in-element fragment}}
    text in a fragment
    {{log "in-element body"}}
    {{state.count}}

    <button onclick={{state.inc}}>inc</button>
  {{/in-element}}
</template> 

let element = check(
valueForRef(elementRef),
CheckOr(CheckElement, CheckDocumentFragment)
) as SimpleElement;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's not cast. Push the type change to the implementation so that we can really show it's always safe to pass document fragments.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

type changed pushed here: 0e0c6d5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewing the type changes makes me think we're missing something here, and the preexisting uses of SimpleElement that this PR is forced to expand to SimpleElement | SimpleDocumentFragment should possibly go the other direction to SimpleNode. For example, this:

export interface Bounds {
  // ...
  parentElement(): SimpleElement;
  // ...
}

Should probably really be:

export interface Bounds {
  // ...
  parentNode(): SimpleNode;
  // ...
}

And then you would get support for DocumentFragment (and critically also Document!).

HTML Node's parentElement is defined to always be Element or null. It's never a document fragment or document. Whereas parentNode is allowed to be a DocumentFragment, etc.

I think the existing types were unnecessarily specific when they used SimpleElement instead of SimpleNode in most o the places this PR had to touch. Most of them a clearly not relying on any Element-specific API, since they happily tolerate DocumentFragment instead with very few changes.

@lifeart
Copy link
Copy Markdown
Contributor

lifeart commented Apr 7, 2026

I think we need mini-rfc here to discuss expected behaviour for "internal" re-render cases, where new nodes added.

For example:

{{#in-element this.fragment}}
MY_STABLE_NODE
  {{#if this.showMore}}
    UNSTABLE_NODE
  {{/if}}
{{/#in-element}}

If we assume following lifecycle:

  1. this.fragment return unappended (fresh) fragment
  2. MY_STABLE_NODE rendered to fragment
  3. We somehow append fragment to DOM
  4. We toggle showMore property to render UNSTABLE_NODE

With as-is implementation UNSTABLE_NODE will be rendered to FRAGMENT, and I think this is WRONG behaviour for long-term.
We should render it to node WHERE fragment already appended.

How we could achive it?
We may have marker-lookup html comments inside 'in-element', like:

{{#in-element this.fragment}}
// IN_ELEMENT_COMMENT_START
MY_STABLE_NODE
  {{#if this.showMore}}
    UNSTABLE_NODE
  {{/if}}
// IN_ELEMENT_COMMENT_END
{{/#in-element}}

and resolve node to append from markers

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

With as-is implementation UNSTABLE_NODE will be rendered to FRAGMENT, and I think this is WRONG behaviour for long-term.

why is this wrong? UNSTABLE_NODE is in-element'd the fragment

@ef4
Copy link
Copy Markdown
Contributor

ef4 commented Apr 7, 2026

I think @lifeart makes a good point. There's a disconnect here between the glimmer renderer -- which is reactive and "timeless", in the sense that you're not supposed to have to think about the exact timing of things going in and out of the DOM -- and the imperative use of appendChild(fragment).

If the top-level elements don't follow the rest of the content after the fragment has been applied elsewhere, you get nasty timing-dependent behavior that can break at surprising times. Consider:

{{#in-element this.fragment}}
  <SomeComponent />
{{/#in-element}}

You have that and it's working fine. Then somebody changes <SomeComponent /> like this:

const SomeComponent = <template>
    the message is:
+  {{#if resource.isReady}}
    {{resource.message}}
+  {{/if}}
</template>

Now your content is smeared across two locations.

But yeah, I think this does have design tradeoffs either way. Even if we tried to use a strategy like the suggested comment markers, it's still quite timing dependent. If somebody tries to append a fragment more than once, the outcome will depend a lot on timing, whether or not we have comment markers.

I guess I need to hear more about people's use cases for fragments, and how they think they can manage the timing of deciding when it's OK to append them.

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

NullVoxPopuli commented Apr 7, 2026

I believe this is a repro of the described problem

it seems like the VM is running everything, but the dom is just disconnected

@NullVoxPopuli NullVoxPopuli marked this pull request as draft April 7, 2026 14:40
@NullVoxPopuli
Copy link
Copy Markdown
Contributor

the timing of deciding when

I don't think we should have a timing issue to worry about as far as rendering is concerned. timing only matters for in-element, which is no different from when you can find a regular dom node -- it must exist in the dom already in order to use in-element, which is why there are libraries wrapping in-element for various behaviors abstracting this.

use cases

the primary one is more for us actually, where we batch render loops and such to a fragment instead of an actual node, and then when we finish that batch we add the fragment to the dom. this has seemed to be much faster than directly manipulating the dom

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

and it turns out this is a prereq for shadow-dom support, since a shadow root is a sub type of document fragment

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.

4 participants