Correct ChildNode replacement and insertion order - #681
olavoasantos wants to merge 1 commit into
Conversation
0d303a7 to
5821126
Compare
5821126 to
6676e1d
Compare
|
Could we stage argument coercion before Repro ( const parent = document.createElement('div');
const receiver = document.createElement('span');
parent.appendChild(receiver);
let conversionCalls = 0;
const conversionError = new TypeError('conversion failed');
const value = {
toString() {
conversionCalls++;
throw conversionError;
},
};
receiver.before(value, parent);Compared native Chrome 152, this PR's namespace base, and head
The issue is the ordering in ChildNode.ts, not just the exception name. Suggested fix: bring the narrow argument-staging approach from #692 into this PR: const staged = nodes.map((node) =>
node instanceof Node ? node : String(node),
);
validateNodesForInsertion(parent, staged);
// Use staged arguments for the remaining operation.Keep coercion separate from fragment assembly/tree mutation; don't simply move the entire The existing conversion-failure test is useful, but doesn't combine a throwing value with a later cyclic argument. Please add that combination for all three methods, asserting the original error object is thrown, conversion runs exactly once, and this failed conversion leaves the tree unchanged. |
| 'cannot insert a node into itself or one of its descendants', | ||
| ); | ||
| } | ||
| ancestor = ancestor.parentNode; |
There was a problem hiding this comment.
Could this use the same host-inclusive ancestor walk as ParentNode.validateInsertion (PARENT ?? HOST). That would add automatic support for template.content
There was a problem hiding this comment.
yea, switched the ancestor walk to PARENT ?? HOST so the prevalidation catches cycles through template.content before any earlier argument is moved.
6676e1d to
9a2a747
Compare
9a2a747 to
bac8a0f
Compare
|
Agreed. |
bac8a0f to
cb81109
Compare
| if (convertedNodes.length === 1) return convertedNodes[0]!; | ||
|
|
||
| const fragment = parent.ownerDocument.createDocumentFragment(); | ||
| for (const node of convertedNodes) fragment.appendChild(node); |
There was a problem hiding this comment.
IIUC, won't this fire custom element reactions prematurely, when instead we would want to delay those until after this entire operation is done? I.e. if a custom element is already mounted, and then it is moved, it should never see this move to the fragment. My agent came up with this test case to demonstrate the issue:
it('commits replacements before disconnecting a moved custom element', () => {
const parent = document.createElement('div');
const receiver = document.createElement('em');
const second = document.createElement('span');
let observedChildren: unknown[] = [];
class MovingElement extends PolyfillHTMLElement {
disconnectedCallback() {
observedChildren = [...parent.childNodes];
}
}
polyfillWindow.customElements.define(
'moving-element',
MovingElement as unknown as CustomElementConstructor,
);
const first = document.createElement('moving-element');
parent.appendChild(receiver);
document.body.append(parent, first); // first must already be connected
receiver.replaceWith(first, second);
expect([...parent.childNodes]).toEqual([first, second]); // passes
expect(observedChildren).toEqual([first, second]); // fails: [receiver]
});Should we expose a private method on ParentNode that any public insertion method can use to do the actual insertion that takes an array of children to insert so we can avoid having to go through fragments all together? Just one thought off the top of my head.
There was a problem hiding this comment.
yea, this reproduced. convertNodesIntoNode() was staging through public fragment.appendChild(), so that nested reaction boundary flushed while the destination still contained receiver.
i kept the fragment conversion instead of adding the array-based mutation path, but routed both staging and the final commit through internal ParentNode primitives and collect hook effects until all links are committed. before(), after(), and replaceWith() now cover moved connected custom elements, and argument conversion happens before reading parentNode too. Your exact case passes on 9904cdd, and fresh CI is green. i force-updated the head, so this needs another look.
cb81109 to
9904cdd
Compare
Problem
Upstream PR #623 already landed the baseline
replaceWith()repair: ordinary replacement no longer passes the old and new nodes backwards, and zero- and multi-argument calls have their basic placement semantics. The remaining issue was thatreplaceWith(),before(), andafter()still changed the tree one argument at a time. A later cyclic node or throwing string conversion could leave earlier nodes moved and the receiver removed; custom-element reactions could also observe or reenter an incomplete multi-node operation.Impact
Important. A throwing convenience mutation could leave connected trees partially changed, with sibling links, lifecycle callbacks, and hook effects reflecting only a prefix of the requested operation. This is especially hazardous for a worker-side DOM that must stay coherent with its remote receiver.
Upstream #623 landed the baseline fix while this stack was being prepared, narrowing this PR to the remaining ordering, validation, atomicity, and reentrancy cases.
Reproduction
The second replacement is the receiver’s parent and is invalid. Before this change, the operation could remove
receiverand movereplacementbefore reaching the invalid argument. It now throws before mutating either tree:parentstill containsreceiverandsourcestill containsreplacement.Change
Validate every node argument before changing links, then convert the complete argument list into one node or a document fragment and commit it in one parent operation. Anchor around arguments that are already adjacent to the receiver so their supplied order is preserved. Wrap
replaceWith(),before(), andafter()in the custom-element reaction boundary, ensuring all structural changes are committed before reactions can run or reenter.This PR deliberately builds on #623 rather than redescribing its already-landed baseline fix; its scope is the remaining argument validation, ordering, atomicity, and reentrancy behavior.
Tests
New regression coverage verifies
replaceWith()with mixed nodes and strings, adjacent/self receiver arguments, an invalid first or later cyclic argument, and a reentrant disconnect callback that observes the complete replacement before removing one replacement node. Companionbefore()andafter()coverage verifies argument order, complete-tree visibility to connected callbacks, late conversion failures without moves or hooks, lifecycle-error behavior, and late cyclic arguments without partial mutation.Stack
normalize-dom-namespacesValidation
Fresh GitHub CI on restacked head
6676e1dpasses: