|
| 1 | +// These tests ensure that: |
| 2 | +// 1. The HTML element insertion steps for iframes [1] run *after* all DOM |
| 3 | +// insertion mutations associated with any given call to |
| 4 | +// #concept-node-insert [2] (which may insert many elements at once). |
| 5 | +// Consequently, a preceding element's insertion steps can observe the |
| 6 | +// side-effects of later elements being connected to the DOM, but cannot |
| 7 | +// observe the side-effects of the later element's own insertion steps [1], |
| 8 | +// since insertion steps are run in order after all DOM insertion mutations |
| 9 | +// are complete. |
| 10 | +// 2. The HTML element removing steps for iframes [3] *do not* synchronously |
| 11 | +// run script during child navigable destruction. Therefore, script cannot |
| 12 | +// observe the state of the DOM in the middle of iframe removal, even when |
| 13 | +// multiple iframes are being removed in the same task. Iframe removal, |
| 14 | +// from the perspective of the parent's DOM tree, is atomic. |
| 15 | +// |
| 16 | +// [1]: https://html.spec.whatwg.org/C#the-iframe-element:html-element-insertion-steps |
| 17 | +// [2]: https://dom.spec.whatwg.org/#concept-node-insert |
| 18 | +// [3]: https://html.spec.whatwg.org/C#the-iframe-element:html-element-removing-steps |
| 19 | + |
| 20 | +promise_test(async t => { |
| 21 | + const fragment = new DocumentFragment(); |
| 22 | + |
| 23 | + const iframe1 = fragment.appendChild(document.createElement('iframe')); |
| 24 | + const iframe2 = fragment.appendChild(document.createElement('iframe')); |
| 25 | + |
| 26 | + t.add_cleanup(() => { |
| 27 | + iframe1.remove(); |
| 28 | + iframe2.remove(); |
| 29 | + }); |
| 30 | + |
| 31 | + let iframe1Loaded = false, iframe2Loaded = false; |
| 32 | + iframe1.onload = e => { |
| 33 | + // iframe1 assertions: |
| 34 | + iframe1Loaded = true; |
| 35 | + assert_equals(window.frames.length, 1, |
| 36 | + "iframe1 load event can observe its own participation in the frame " + |
| 37 | + "tree"); |
| 38 | + assert_equals(iframe1.contentWindow, window.frames[0]); |
| 39 | + |
| 40 | + // iframe2 assertions: |
| 41 | + assert_false(iframe2Loaded, |
| 42 | + "iframe2's load event hasn't fired before iframe1's"); |
| 43 | + assert_true(iframe2.isConnected, |
| 44 | + "iframe1 can observe that iframe2 is connected to the DOM..."); |
| 45 | + assert_equals(iframe2.contentWindow, null, |
| 46 | + "... but iframe1 cannot observe iframe2's contentWindow because " + |
| 47 | + "iframe2's insertion steps have not been run yet"); |
| 48 | + }; |
| 49 | + |
| 50 | + iframe2.onload = e => { |
| 51 | + iframe2Loaded = true; |
| 52 | + assert_equals(window.frames.length, 2, |
| 53 | + "iframe2 load event can observe its own participation in the frame tree"); |
| 54 | + assert_equals(iframe1.contentWindow, window.frames[0]); |
| 55 | + assert_equals(iframe2.contentWindow, window.frames[1]); |
| 56 | + }; |
| 57 | + |
| 58 | + // Synchronously consecutively adds both `iframe1` and `iframe2` to the DOM, |
| 59 | + // invoking their insertion steps (and thus firing each of their `load` |
| 60 | + // events) in order. `iframe1` will be able to observe itself in the DOM but |
| 61 | + // not `iframe2`, and `iframe2` will be able to observe both itself and |
| 62 | + // `iframe1`. |
| 63 | + document.body.append(fragment); |
| 64 | + assert_true(iframe1Loaded, "iframe1 loaded"); |
| 65 | + assert_true(iframe2Loaded, "iframe2 loaded"); |
| 66 | +}, "Insertion steps: load event fires synchronously *after* iframe DOM " + |
| 67 | + "insertion, as part of the iframe element's insertion steps"); |
| 68 | + |
| 69 | +// There are several versions of the removal variant, since there are several |
| 70 | +// ways to remove multiple elements "at once". For example: |
| 71 | +// 1. `node.innerHTML = ''` ultimately runs |
| 72 | +// https://dom.spec.whatwg.org/#concept-node-replace-all which removes all |
| 73 | +// of a node's children. |
| 74 | +// 2. `node.replaceChildren()` which follows roughly the same path above. |
| 75 | +// 3. `node.remove()` on a parent of many children will invoke not the DOM |
| 76 | +// remove algorithm, but rather the "removing steps" hook [1], for each |
| 77 | +// child. |
| 78 | +// |
| 79 | +// [1]: https://dom.spec.whatwg.org/#concept-node-remove-ext |
| 80 | + |
| 81 | +function runRemovalTest(removal_method) { |
| 82 | + promise_test(async t => { |
| 83 | + const div = document.createElement('div'); |
| 84 | + |
| 85 | + const iframe1 = div.appendChild(document.createElement('iframe')); |
| 86 | + const iframe2 = div.appendChild(document.createElement('iframe')); |
| 87 | + document.body.append(div); |
| 88 | + |
| 89 | + // Now that both iframes have been inserted into the DOM, we'll set up a |
| 90 | + // MutationObserver that we'll use to ensure that multiple synchronous |
| 91 | + // mutations (removals) are only observed atomically at the end. Specifically, |
| 92 | + // the observer's callback is not invoked synchronously for each removal. |
| 93 | + let observerCallbackInvoked = false; |
| 94 | + const removalObserver = new MutationObserver(mutations => { |
| 95 | + assert_false(observerCallbackInvoked, |
| 96 | + "MO callback is only invoked once, not multiple times, i.e., for " + |
| 97 | + "each removal"); |
| 98 | + observerCallbackInvoked = true; |
| 99 | + assert_equals(mutations.length, 1, "Exactly one MutationRecord is recorded"); |
| 100 | + assert_equals(mutations[0].removedNodes.length, 2); |
| 101 | + assert_equals(window.frames.length, 0, |
| 102 | + "No iframe Windows exist when the MO callback is run"); |
| 103 | + assert_equals(document.querySelector('iframe'), null, |
| 104 | + "No iframe elements are connected to the DOM when the MO callback is " + |
| 105 | + "run"); |
| 106 | + }); |
| 107 | + |
| 108 | + removalObserver.observe(div, {childList: true}); |
| 109 | + t.add_cleanup(() => removalObserver.disconnect()); |
| 110 | + |
| 111 | + let iframe1UnloadFired = false, iframe2UnloadFired = false; |
| 112 | + iframe1.contentWindow.addEventListener('unload', e => iframe1UnloadFired = true); |
| 113 | + iframe2.contentWindow.addEventListener('unload', e => iframe2UnloadFired = true); |
| 114 | + |
| 115 | + // Each `removal_method` will trigger the synchronous removal of each of |
| 116 | + // `div`'s (iframe) children. This will synchronously, consecutively |
| 117 | + // invoke HTML's "destroy a child navigable" (per [1]), for each iframe. |
| 118 | + // |
| 119 | + // [1]: https://html.spec.whatwg.org/C#the-iframe-element:destroy-a-child-navigable |
| 120 | + |
| 121 | + if (removal_method === 'replaceChildren') { |
| 122 | + div.replaceChildren(); |
| 123 | + } else if (removal_method === 'remove') { |
| 124 | + div.remove(); |
| 125 | + } else if (removal_method === 'innerHTML') { |
| 126 | + div.innerHTML = ''; |
| 127 | + } |
| 128 | + |
| 129 | + assert_false(iframe1UnloadFired, "iframe1 unload did not fire"); |
| 130 | + assert_false(iframe2UnloadFired, "iframe2 unload did not fire"); |
| 131 | + |
| 132 | + assert_false(observerCallbackInvoked, |
| 133 | + "MO callback is not invoked synchronously after removals"); |
| 134 | + |
| 135 | + // Wait one microtask. |
| 136 | + await Promise.resolve(); |
| 137 | + |
| 138 | + if (removal_method !== 'remove') { |
| 139 | + assert_true(observerCallbackInvoked, |
| 140 | + "MO callback is invoked asynchronously after removals"); |
| 141 | + } |
| 142 | + }, `Removing steps (${removal_method}): script does not run synchronously during iframe destruction`); |
| 143 | +} |
| 144 | + |
| 145 | +runRemovalTest('innerHTML'); |
| 146 | +runRemovalTest('replaceChildren'); |
| 147 | +runRemovalTest('remove'); |
0 commit comments