Skip to content

Preserve namespaces and template content when cloning nodes - #683

Open
olavoasantos wants to merge 1 commit into
mainfrom
preserve-clone-namespaces
Open

olavoasantos wants to merge 1 commit into
mainfrom
preserve-clone-namespaces

Conversation

@olavoasantos

@olavoasantos olavoasantos commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Problem

cloneNode() and Document.importNode() recreated every element through the HTML-element factory and copied only ordinary childNodes. Consequently, cloning an SVG element lost its namespace and concrete SVGElement shape, while a deep clone or import of an HTMLTemplateElement omitted the separate template.content fragment.

Impact

Important. Consumers that clone or import implemented SVG/template DOM can receive a tree with incompatible namespace and constructor semantics or silently lose template content. That can make subsequent namespace-sensitive DOM operations and Remote DOM output disagree with the source tree.

Reproduction

const svg = document.createElementNS(
  'http://www.w3.org/2000/svg',
  'svg:linearGradient',
);
const template = document.createElement('template');
template.innerHTML = '<strong>message</strong>';

const svgCopy = svg.cloneNode(true);
const templateCopy = template.cloneNode(true);

Before this change, svgCopy was an HTML Element in the HTML namespace, and templateCopy.content was empty. It now preserves the SVG namespace, qualified name, and SVGElement shape, and a deep template clone contains an independent copy of its content.

Change

  • Recreate elements through the namespace-aware internal element factory, preserving qualified names, prefixes, concrete element shape, and attributes.
  • Clone attributes as detached nodes with the destination document.
  • Iteratively copy ordinary descendants and, for deep HTML-template clones/imports, the template content fragment as well.
  • Reject cyclic or repeated malformed node graphs rather than looping while cloning them.

The iterative traversal also avoids overflowing the call stack on deeply nested template content while retaining clone hook ordering.

Tests

Adds packages/polyfill/source/tests/clone-import-namespaces.test.ts, covering shallow and deep SVG clone behavior; namespace-qualified elements and attributes; clone/import owner-document assignment; shallow and deep template behavior including nested templates; detached attribute cloning; clone hook ordering; malformed cyclic/repeated graphs; and 6,000-level template-content clone and import cases.

Stack

Validation

Fresh GitHub CI on restacked head 9037ed9 passes:

  • lint;
  • type-check and build;
  • unit tests with coverage;
  • bundle-size checks;
  • Playwright end-to-end tests;
  • the classified Web Platform Test suite.

@olavoasantos
olavoasantos force-pushed the preserve-clone-namespaces branch from 8799c1f to 4b9db35 Compare September 3, 2026 15:29
@olavoasantos
olavoasantos changed the base branch from adopt-inserted-subtrees to normalize-dom-namespaces September 3, 2026 15:30
@olavoasantos
olavoasantos force-pushed the preserve-clone-namespaces branch from 4b9db35 to 9037ed9 Compare September 4, 2026 14:31
@olavoasantos
olavoasantos marked this pull request as ready for review September 14, 2026 18:41

@henrytao-me henrytao-me left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@olavoasantos
olavoasantos force-pushed the preserve-clone-namespaces branch from 9037ed9 to acf7f14 Compare September 16, 2026 13:57
@olavoasantos
olavoasantos force-pushed the preserve-clone-namespaces branch from acf7f14 to 2c806da Compare September 16, 2026 14:08
@olavoasantos
olavoasantos force-pushed the preserve-clone-namespaces branch from 2c806da to f9873d5 Compare September 16, 2026 20:13
@olavoasantos
olavoasantos removed this pull request from stack #700 September 16, 2026 20:14
@olavoasantos
olavoasantos changed the base branch from normalize-dom-namespaces to main September 16, 2026 20:14
@olavoasantos
olavoasantos added this pull request to stack #717 September 16, 2026 20:15
@henrytao-me

Copy link
Copy Markdown
Member

One correction from re-review of f9873d5: the global visited check can reject a valid source tree when a constructor or creation hook moves a previously copied node.

Example: the source is root -> [first(leaf), later]. Cloning finishes first, then creating later moves the original leaf into the original later. The source remains a valid tree, but the live traversal / global visited check revisits it and throws Cannot clone a cyclic or repeated node graph.

Reproduced with both cloneNode(true) and importNode(..., true), through custom-element constructors and independently through the polyfill's createElement hook. Native Chrome completes the constructor fixture without throwing. The base also completes, but its eager constructor timing duplicates the copied leaf; that base output should not become the native-correct test oracle. This also occurs on the revision I previously marked scoped Good; the expanded reentrancy coverage caught a miss in that earlier pass.

Suggested change

Move graph validation into an iterative topology-snapshot pass before the first cloneNodeShallow() call, then consume the captured lists in the existing clone loop. Do not just remove the guard or skip already-seen nodes.

interface CloneSnapshot {
  children: Node[];
  content?: DocumentFragment;
}

function snapshotCloneTree(root: Node) {
  const snapshots = new Map<Node, CloneSnapshot>();
  const pending = [root];

  while (pending.length > 0) {
    const source = pending.pop()!;
    if (snapshots.has(source)) throwCloneGraphError();

    const children =
      isElementNode(source) || isDocumentFragmentNode(source)
        ? Array.from(source.childNodes)
        : [];

    const content =
      isElementNode(source) &&
      source.namespaceURI === HTML_NAMESPACE &&
      source.localName === 'template'
        ? (source as HTMLTemplateElement)[CONTENT]
        : undefined;

    snapshots.set(source, {children, content});
    if (content) pending.push(content);
    for (let index = children.length - 1; index >= 0; index--) {
      pending.push(children[index]!);
    }
  }

  return snapshots;
}

Keep the shallow/leaf fast path. For deep container clones, start with:

const snapshots = snapshotCloneTree(node);
const cloned = cloneNodeShallow(node, document);

Then replace the three live reads in the existing frame loop:

// Ordinary children:
sourceChild = snapshots.get(frame.source)!.children[frame.childIndex++];

// Initialized template content:
const content = snapshots.get(frame.source)!.content;

// Content children:
sourceChild = snapshots.get(frame.contentSource)!.children[frame.childIndex++];

Remove the clone loop's visited set/checks: the snapshot map now validates duplicate entries/cycles before cloning effects. Snapshot the whole topology upfront, not one frame at a time, since a callback can mutate a later branch before it is visited. Reading [CONTENT], not .content, preserves lazy initialization. The existing clone/append hook order stays in the second pass.

Please add constructor and hook reentrancy regressions for both clone/import, while retaining the malformed-graph and deep-template tests.

Validation: a build-only candidate makes both constructor repros match native output and leaves the other 208 comparison outcomes unchanged. Both hook-move repros, three malformed-graph controls, and 6,000-level clone/import checks pass. This is a probe-validated suggestion, not a production patch or full-suite/typechecked candidate. The unmodified PR head passes 347 polyfill tests, 63 focused core tests, and the locked polyfill compiler/changed-file formatting checks.

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.

3 participants