Skip to content

Bug: data-live-preserve breaks when parent element's ID changes during morph #3423

@Pechynho

Description

@Pechynho

Symfony version(s) affected

2.34.0

Description

Elements with data-live-preserve lose their preserved DOM state when any ancestor element's ID changes between re-renders. The innerHTML shortcut path in the morphdom logic bypasses the preserve mechanism entirely, causing the preserved element to be replaced with a freshly parsed DOM node.

How to reproduce

<div id="parent-live-component">
    <div id="container-with-random-id-on-every-render-of-parent-component-123">
        <div id="child-live-component">...</div>
    </div>
</div>

After rerender of "parent-live-component", server returns something like this

<div id="parent-live-component">
    <div id="container-with-random-id-on-every-render-of-parent-component-789">
        <div id="child-live-component" data-live-preserve></div>
    </div>
</div>

The child component, which should be preserved loses all its DOM state.

Root cause

In https://github.com/symfony/ux/blob/2.x/src/LiveComponent/assets/src/morphdom.ts, the beforeNodeMorphed callback has this logic:

  if (fromEl.hasAttribute('data-skip-morph') || (fromEl.id && fromEl.id !== toEl.id)) {
      fromEl.innerHTML = toEl.innerHTML;
      return true;
  }

When a parent element's ID changes (fromEl.id !== toEl.id), this code does a brute-force innerHTML replacement of all children. This is a native DOM operation — Idiomorph's callbacks (beforeNodeMorphed, beforeNodeRemoved) are never invoked for any of the children.

The data-live-preserve mechanism relies on those callbacks to skip morphing preserved elements. Since they're never called, the preserved element is silently destroyed and replaced by a fresh element parsed from the HTML string. The original element remains orphaned in the originalElementsToPreserve map, and its ID is never added to originalElementIdsToSwapAfter, so the post-morph swap never runs.

Suggested fix

After the innerHTML swap, restore any preserved elements that were inside the affected parent:

  if (fromEl.hasAttribute('data-skip-morph') || (fromEl.id && fromEl.id !== toEl.id)) {
      fromEl.innerHTML = toEl.innerHTML;

      // Restore preserved elements destroyed by innerHTML swap
      originalElementsToPreserve.forEach((originalElement, id) => {
          const placeholder = fromEl.querySelector(`#${id}`);
          if (placeholder) {
              syncAttributes(placeholder, originalElement);
              placeholder.replaceWith(originalElement);
          }
      });

      return true;
  }

Possible Solution

No response

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions