Skip to content

[BUGFIX] {{#in-element}} with DocumentFragment targets, continued - #21550

Closed
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:in-element-document-fragment
Closed

[BUGFIX] {{#in-element}} with DocumentFragment targets, continued#21550
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:in-element-document-fragment

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Continues #21253 (rebased onto latest main, squashed to resolve conflicts with the import-style changes on main), and addresses the review discussion there.

What this adds

{{#in-element}} accepts a DocumentFragment (including template.content and ShadowRoot) as its destination, and rendered content keeps working after the fragment's children are moved into the DOM via container.appendChild(fragment).

Review feedback addressed

Types go to SimpleNode, not SimpleElement | SimpleDocumentFragment (@ef4)

Instead of widening SimpleElement at each use site, the over-specific SimpleElement types moved to SimpleNode, and Bounds#parentElement() is renamed to Bounds#parentNode(). DOM's parentElement is always an Element or null, while parentNode may be an Element, DocumentFragment, or Document, so the name now matches what the method can return. The casts in clear()/resume() are gone; SimpleNodeBase already carries parentNode/insertBefore/removeChild, so everything checks without assertions. The one remaining runtime check is the CheckElement | CheckDocumentFragment debug check on the in-element destination itself.

Two serialized shapes intentionally keep the parentElement key with a widened type rather than being renamed: CapturedRenderNode['bounds'] and Renderer#getBounds, since ember-inspector consumes them.

Updates follow the content, not the fragment (@lifeart, @ef4)

The "smeared across two locations" scenario is now handled and tested: bounds resolve their mutation parent from firstNode().parentNode (the live location) with the stored parent as fallback. The marker mechanism @lifeart proposed effectively already exists: every block, including an empty {{#if}}, leaves at least a comment node in the DOM, and those markers migrate with the fragment's children, anchoring the live location.

This applies uniformly to:

  • clear() and move() in bounds.ts (block teardown and {{#each}} reordering)
  • NewTreeBuilder.resume() (re-rendering a resettable block, e.g. toggling {{#if}})
  • the {{#each}} sync-marker and item-insertion paths in update.ts, which anchor on the live reference node's parent (the list's own bounds are mid-initialization during sync(), so they can't supply it)
  • the RemoteBlock destructor (teardown after the content migrated)

New tests cover: toggling an {{#if}} on/off after attachment (renders next to the migrated content in the container, never into the stale fragment), {{#each}} append/reorder/remove after attachment, destroying the whole {{#in-element}} after attachment (clears the container; re-creating renders into the again-detached fragment), and a ShadowRoot destination (the shadow-DOM prerequisite from the discussion).

Out of scope

  • Making fragments renderable as values ({{this.fragment}}) — separate PR, per discussion.
  • Destroying rendered content when the fragment's host is imperatively removed from the DOM. Glimmer doesn't observe external DOM mutation; teardown is driven by template lifecycle (toggling/destroying the {{#in-element}}), which is tested.
  • insertBefore values other than null/undefined (unchanged from Element destinations).

NullVoxPopuli-ai-agent and others added 4 commits August 7, 2026 17:12
Combined state of emberjs#21253
rebased onto latest main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review on emberjs#21253: instead of widening SimpleElement to
SimpleElement | SimpleDocumentFragment at every use site, move the
over-specific types to SimpleNode. DOM's parentElement is defined to
always be an Element or null, while parentNode may be an Element,
DocumentFragment, or Document — so the method is renamed to match what
it can actually return.

Also drop the casts in clear()/resume() (SimpleNodeBase already carries
parentNode/insertBefore/removeChild), extract the live-parent resolution
into liveParent(), and use it in move() and the list-update paths in
update.ts so {{#each}} insertions, reorders, and the sync marker also
follow content that migrated out of a DocumentFragment.

The serialized CapturedRenderNode['bounds'].parentElement key and
Renderer#getBounds shape are intentionally unchanged so ember-inspector
keeps working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gets

Covers the behavior discussed in emberjs#21253:

- After container.appendChild(fragment), toggling an {{#if}} renders the
  new element into the container next to the migrated content (the
  empty-block comment marker migrates with the fragment's children and
  anchors the live location), and removes it from the container again.
- Destroying the {{#in-element}} after attachment clears the content
  from the container, and re-creating it renders into the (again
  detached) fragment.
- {{#each}} append/reorder/remove all mutate the container, never the
  stale fragment.
- {{#in-element}} accepts a ShadowRoot (a DocumentFragment subtype).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli

Copy link
Copy Markdown
Contributor
duration phase no difference [-53ms to 23ms]
renderEnd phase no difference [-1ms to 0ms]
render1000Items1End phase no difference [-3ms to 7ms]
clearItems1End phase no difference [0ms to 8ms]
render1000Items2End phase no difference [0ms to 0ms]
clearItems2End phase no difference [0ms to 0ms]
render10000Items1End phase no difference [-2ms to 7ms]
clearManyItems1End phase no difference [-4ms to 4ms]
render10000Items2End phase no difference [0ms to 0ms]
clearManyItems2End phase no difference [0ms to 0ms]
render1000Items3End phase no difference [0ms to 0ms]
append1000Items1End phase no difference [0ms to 0ms]
append1000Items2End phase no difference [-33ms to 0ms]
updateEvery10thItem1End phase no difference [0ms to 0ms]
updateEvery10thItem2End phase no difference [0ms to 0ms]
selectFirstRow1End phase no difference [0ms to 0ms]
selectSecondRow1End phase no difference [0ms to 0ms]
removeFirstRow1End phase no difference [0ms to 0ms]
removeSecondRow1End phase no difference [0ms to 0ms]
swapRows1End phase no difference [0ms to 0ms]
swapRows2End phase no difference [-44ms to 0ms]
clearItems4End phase no difference [0ms to 10ms]
paint phase no difference [-4ms to 0ms]

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

Continued over here: #21551

@NullVoxPopuli
NullVoxPopuli deleted the in-element-document-fragment branch August 7, 2026 21:47
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.

2 participants