<!DOCTYPE html> <html> <head> <title>Shadow DOM: slotchange event</title> <meta name="author" title="Ryosuke Niwa" href="mailto:rniwa@webkit.org"> <meta name="author" title="Hayato Ito" href="mailto:hayato@google.com"> <link rel="help" href="https://dom.spec.whatwg.org/#signaling-slot-change"> <script src="/resources/testharness.js"></script> <script src="/resources/testharnessreport.js"></script> </head> <body> <div id="log"></div> <script> function treeName(mode, connectedToDocument) { return (mode == 'open' ? 'an ' : 'a ') + mode + ' shadow root ' + (connectedToDocument ? '' : ' not') + ' in a document'; } function testAppendingSpanToShadowRootWithDefaultSlot(mode, connectedToDocument) { var test = async_test('slotchange event must fire on a default slot element inside ' + treeName(mode, connectedToDocument)); var host; var slot; var eventCount = 0; test.step(function () { host = document.createElement('div'); if (connectedToDocument) document.body.appendChild(host); var shadowRoot = host.attachShadow({'mode': mode}); slot = document.createElement('slot'); slot.addEventListener('slotchange', function (event) { if (event.isFakeEvent) return; test.step(function () { assert_equals(event.type, 'slotchange', 'slotchange event\'s type must be "slotchange"'); assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element'); assert_equals(event.relatedTarget, undefined, 'slotchange must not set relatedTarget'); }); eventCount++; }); shadowRoot.appendChild(slot); host.appendChild(document.createElement('span')); host.appendChild(document.createElement('b')); assert_equals(eventCount, 0, 'slotchange event must not be fired synchronously'); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 1, 'slotchange must be fired exactly once after the assigned nodes changed'); host.appendChild(document.createElement('i')); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 2, 'slotchange must be fired exactly once after the assigned nodes changed'); host.appendChild(document.createTextNode('hello')); var fakeEvent = new Event('slotchange'); fakeEvent.isFakeEvent = true; slot.dispatchEvent(fakeEvent); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 3, 'slotchange must be fired exactly once after the assigned nodes changed' + ' event if there was a synthetic slotchange event fired'); }); test.done(); }, 1); }, 1); }, 1); } testAppendingSpanToShadowRootWithDefaultSlot('open', true); testAppendingSpanToShadowRootWithDefaultSlot('closed', true); testAppendingSpanToShadowRootWithDefaultSlot('open', false); testAppendingSpanToShadowRootWithDefaultSlot('closed', false); function testAppendingSpanToShadowRootWithNamedSlot(mode, connectedToDocument) { var test = async_test('slotchange event must fire on a named slot element inside' + treeName(mode, connectedToDocument)); var host; var slot; var eventCount = 0; test.step(function () { host = document.createElement('div'); if (connectedToDocument) document.body.appendChild(host); var shadowRoot = host.attachShadow({'mode': mode}); slot = document.createElement('slot'); slot.name = 'someSlot'; slot.addEventListener('slotchange', function (event) { if (event.isFakeEvent) return; test.step(function () { assert_equals(event.type, 'slotchange', 'slotchange event\'s type must be "slotchange"'); assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element'); assert_equals(event.relatedTarget, undefined, 'slotchange must not set relatedTarget'); }); eventCount++; }); shadowRoot.appendChild(slot); var span = document.createElement('span'); span.slot = 'someSlot'; host.appendChild(span); var b = document.createElement('b'); b.slot = 'someSlot'; host.appendChild(b); assert_equals(eventCount, 0, 'slotchange event must not be fired synchronously'); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 1, 'slotchange must be fired exactly once after the assigned nodes changed'); var i = document.createElement('i'); i.slot = 'someSlot'; host.appendChild(i); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 2, 'slotchange must be fired exactly once after the assigned nodes changed'); var em = document.createElement('em'); em.slot = 'someSlot'; host.appendChild(em); var fakeEvent = new Event('slotchange'); fakeEvent.isFakeEvent = true; slot.dispatchEvent(fakeEvent); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 3, 'slotchange must be fired exactly once after the assigned nodes changed' + ' event if there was a synthetic slotchange event fired'); }); test.done(); }, 1); }, 1); }, 1); } testAppendingSpanToShadowRootWithNamedSlot('open', true); testAppendingSpanToShadowRootWithNamedSlot('closed', true); testAppendingSpanToShadowRootWithNamedSlot('open', false); testAppendingSpanToShadowRootWithNamedSlot('closed', false); function testSlotchangeDoesNotFireWhenOtherSlotsChange(mode, connectedToDocument) { var test = async_test('slotchange event must not fire on a slot element inside ' + treeName(mode, connectedToDocument) + ' when another slot\'s assigned nodes change'); var host; var defaultSlotEventCount = 0; var namedSlotEventCount = 0; test.step(function () { host = document.createElement('div'); if (connectedToDocument) document.body.appendChild(host); var shadowRoot = host.attachShadow({'mode': mode}); var defaultSlot = document.createElement('slot'); defaultSlot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, defaultSlot, 'slotchange event\'s target must be the slot element'); }); defaultSlotEventCount++; }); var namedSlot = document.createElement('slot'); namedSlot.name = 'slotName'; namedSlot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, namedSlot, 'slotchange event\'s target must be the slot element'); }); namedSlotEventCount++; }); shadowRoot.appendChild(defaultSlot); shadowRoot.appendChild(namedSlot); host.appendChild(document.createElement('span')); assert_equals(defaultSlotEventCount, 0, 'slotchange event must not be fired synchronously'); assert_equals(namedSlotEventCount, 0, 'slotchange event must not be fired synchronously'); }); setTimeout(function () { test.step(function () { assert_equals(defaultSlotEventCount, 1, 'slotchange must be fired exactly once after the assigned nodes change on a default slot'); assert_equals(namedSlotEventCount, 0, 'slotchange must not be fired on a named slot after the assigned nodes change on a default slot'); var span = document.createElement('span'); span.slot = 'slotName'; host.appendChild(span); }); setTimeout(function () { test.step(function () { assert_equals(defaultSlotEventCount, 1, 'slotchange must not be fired on a default slot after the assigned nodes change on a named slot'); assert_equals(namedSlotEventCount, 1, 'slotchange must be fired exactly once after the assigned nodes change on a default slot'); }); test.done(); }, 1); }, 1); } testSlotchangeDoesNotFireWhenOtherSlotsChange('open', true); testSlotchangeDoesNotFireWhenOtherSlotsChange('closed', true); testSlotchangeDoesNotFireWhenOtherSlotsChange('open', false); testSlotchangeDoesNotFireWhenOtherSlotsChange('closed', false); function testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved(mode, connectedToDocument) { var test = async_test('slotchange event must fire on a slot element when a shadow host has a slottable and the slot was inserted' + ' and must not fire when the shadow host was mutated after the slot was removed inside ' + treeName(mode, connectedToDocument)); var host; var slot; var eventCount = 0; test.step(function () { host = document.createElement('div'); if (connectedToDocument) document.body.appendChild(host); var shadowRoot = host.attachShadow({'mode': mode}); slot = document.createElement('slot'); slot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element'); }); eventCount++; }); host.appendChild(document.createElement('span')); shadowRoot.appendChild(slot); assert_equals(eventCount, 0, 'slotchange event must not be fired synchronously'); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 1, 'slotchange must be fired on a slot element if there is assigned nodes when the slot was inserted'); host.removeChild(host.firstChild); }); setTimeout(function () { test.step(function () { assert_equals(eventCount, 2, 'slotchange must be fired after the assigned nodes change on a slot while the slot element was in the tree'); slot.parentNode.removeChild(slot); host.appendChild(document.createElement('span')); }); setTimeout(function () { assert_equals(eventCount, 2, 'slotchange must not be fired on a slot element if the assigned nodes changed after the slot was removed'); test.done(); }, 1); }, 1); }, 1); } testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('open', true); testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('closed', true); testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('open', false); testSlotchangeDoesFireAtInsertedAndDoesNotFireForMutationAfterRemoved('closed', false); function testSlotchangeFiresOnTransientlyPresentSlot(mode, connectedToDocument) { var test = async_test('slotchange event must fire on a slot element inside ' + treeName(mode, connectedToDocument) + ' even if the slot was removed immediately after the assigned nodes were mutated'); var host; var slot; var anotherSlot; var slotEventCount = 0; var anotherSlotEventCount = 0; test.step(function () { host = document.createElement('div'); if (connectedToDocument) document.body.appendChild(host); var shadowRoot = host.attachShadow({'mode': mode}); slot = document.createElement('slot'); slot.name = 'someSlot'; slot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element'); }); slotEventCount++; }); anotherSlot = document.createElement('slot'); anotherSlot.name = 'someSlot'; anotherSlot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, anotherSlot, 'slotchange event\'s target must be the slot element'); }); anotherSlotEventCount++; }); shadowRoot.appendChild(slot); var span = document.createElement('span'); span.slot = 'someSlot'; host.appendChild(span); shadowRoot.removeChild(slot); shadowRoot.appendChild(anotherSlot); var span = document.createElement('span'); span.slot = 'someSlot'; host.appendChild(span); shadowRoot.removeChild(anotherSlot); assert_equals(slotEventCount, 0, 'slotchange event must not be fired synchronously'); assert_equals(anotherSlotEventCount, 0, 'slotchange event must not be fired synchronously'); }); setTimeout(function () { test.step(function () { assert_equals(slotEventCount, 1, 'slotchange must be fired on a slot element if the assigned nodes changed while the slot was present'); assert_equals(anotherSlotEventCount, 1, 'slotchange must be fired on a slot element if the assigned nodes changed while the slot was present'); }); test.done(); }, 1); } testSlotchangeFiresOnTransientlyPresentSlot('open', true); testSlotchangeFiresOnTransientlyPresentSlot('closed', true); testSlotchangeFiresOnTransientlyPresentSlot('open', false); testSlotchangeFiresOnTransientlyPresentSlot('closed', false); function testSlotchangeFiresOnInnerHTML(mode, connectedToDocument) { var test = async_test('slotchange event must fire on a slot element inside ' + treeName(mode, connectedToDocument) + ' when innerHTML modifies the children of the shadow host'); var host; var defaultSlot; var namedSlot; var defaultSlotEventCount = 0; var namedSlotEventCount = 0; test.step(function () { host = document.createElement('div'); if (connectedToDocument) document.body.appendChild(host); var shadowRoot = host.attachShadow({'mode': mode}); defaultSlot = document.createElement('slot'); defaultSlot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, defaultSlot, 'slotchange event\'s target must be the slot element'); }); defaultSlotEventCount++; }); namedSlot = document.createElement('slot'); namedSlot.name = 'someSlot'; namedSlot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, namedSlot, 'slotchange event\'s target must be the slot element'); }); namedSlotEventCount++; }); shadowRoot.appendChild(namedSlot); shadowRoot.appendChild(defaultSlot); host.innerHTML = 'foo <b>bar</b>'; assert_equals(defaultSlotEventCount, 0, 'slotchange event must not be fired synchronously'); assert_equals(namedSlotEventCount, 0, 'slotchange event must not be fired synchronously'); }); setTimeout(function () { test.step(function () { assert_equals(defaultSlotEventCount, 1, 'slotchange must be fired on a slot element if the assigned nodes are changed by innerHTML'); assert_equals(namedSlotEventCount, 0, 'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML'); host.innerHTML = 'baz'; }); setTimeout(function () { test.step(function () { assert_equals(defaultSlotEventCount, 2, 'slotchange must be fired on a slot element if the assigned nodes are changed by innerHTML'); assert_equals(namedSlotEventCount, 0, 'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML'); host.innerHTML = ''; }); setTimeout(function () { test.step(function () { assert_equals(defaultSlotEventCount, 3, 'slotchange must be fired on a slot element if the assigned nodes are changed by innerHTML'); assert_equals(namedSlotEventCount, 0, 'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML'); host.innerHTML = '<b slot="someSlot">content</b>'; }); setTimeout(function () { test.step(function () { assert_equals(defaultSlotEventCount, 3, 'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML'); assert_equals(namedSlotEventCount, 1, 'slotchange must not be fired on a slot element if the assigned nodes are changed by innerHTML'); host.innerHTML = ''; }); setTimeout(function () { test.step(function () { // FIXME: This test would fail in the current implementation because we can't tell // whether a text node was removed in AllChildrenRemoved or not. // assert_equals(defaultSlotEventCount, 3, // 'slotchange must not be fired on a slot element if the assigned nodes are not changed by innerHTML'); assert_equals(namedSlotEventCount, 2, 'slotchange must not be fired on a slot element if the assigned nodes are changed by innerHTML'); }); test.done(); }, 1); }, 1); }, 1); }, 1); }, 1); } testSlotchangeFiresOnInnerHTML('open', true); testSlotchangeFiresOnInnerHTML('closed', true); testSlotchangeFiresOnInnerHTML('open', false); testSlotchangeFiresOnInnerHTML('closed', false); function testSlotchangeFiresWhenNestedSlotChange(mode, connectedToDocument) { var test = async_test('slotchange event must fire on a slot element inside ' + treeName(mode, connectedToDocument) + ' when nested slots\'s contents change'); var outerHost; var innerHost; var outerSlot; var innerSlot; var outerSlotEventCount = 0; var innerSlotEventCount = 0; test.step(function () { outerHost = document.createElement('div'); if (connectedToDocument) document.body.appendChild(outerHost); var outerShadow = outerHost.attachShadow({'mode': mode}); outerShadow.appendChild(document.createElement('span')); outerSlot = document.createElement('slot'); outerSlot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.target, outerSlot, 'slotchange event\'s target must be the slot element'); }); outerSlotEventCount++; }); innerHost = document.createElement('div'); innerHost.appendChild(outerSlot); outerShadow.appendChild(innerHost); var innerShadow = innerHost.attachShadow({'mode': mode}); innerShadow.appendChild(document.createElement('span')); innerSlot = document.createElement('slot'); innerSlot.addEventListener('slotchange', function (event) { event.stopPropagation(); test.step(function () { if (innerSlotEventCount === 0) { assert_equals(event.target, innerSlot, 'slotchange event\'s target must be the inner slot element at 1st slotchange'); } else if (innerSlotEventCount === 1) { assert_equals(event.target, outerSlot, 'slotchange event\'s target must be the outer slot element at 2nd sltochange'); } }); innerSlotEventCount++; }); innerShadow.appendChild(innerSlot); outerHost.appendChild(document.createElement('span')); assert_equals(innerSlotEventCount, 0, 'slotchange event must not be fired synchronously'); assert_equals(outerSlotEventCount, 0, 'slotchange event must not be fired synchronously'); }); setTimeout(function () { test.step(function () { assert_equals(outerSlotEventCount, 1, 'slotchange must be fired on a slot element if the assigned nodes changed'); assert_equals(innerSlotEventCount, 2, 'slotchange must be fired on a slot element and must bubble'); }); test.done(); }, 1); } testSlotchangeFiresWhenNestedSlotChange('open', true); testSlotchangeFiresWhenNestedSlotChange('closed', true); testSlotchangeFiresWhenNestedSlotChange('open', false); testSlotchangeFiresWhenNestedSlotChange('closed', false); function testSlotchangeFiresAtEndOfMicroTask(mode, connectedToDocument) { var test = async_test('slotchange event must fire at the end of current microtask after mutation observers are invoked inside ' + treeName(mode, connectedToDocument) + ' when slots\'s contents change'); var host; var slot; var eventCount = 0; test.step(function () { host = document.createElement('div'); if (connectedToDocument) document.body.appendChild(host); var shadowRoot = host.attachShadow({'mode': mode}); slot = document.createElement('slot'); slot.addEventListener('slotchange', function (event) { test.step(function () { assert_equals(event.type, 'slotchange', 'slotchange event\'s type must be "slotchange"'); assert_equals(event.target, slot, 'slotchange event\'s target must be the slot element'); }); eventCount++; }); shadowRoot.appendChild(slot); }); var element = document.createElement('div'); new MutationObserver(function () { test.step(function () { assert_equals(eventCount, 0, 'slotchange event must not be fired before mutation records are delivered'); }); host.appendChild(document.createElement('span')); element.setAttribute('title', 'bar'); }).observe(element, {attributes: true, attributeFilter: ['id']}); new MutationObserver(function () { test.step(function () { assert_equals(eventCount, 1, 'slotchange event must be fired during a single compound microtask'); }); }).observe(element, {attributes: true, attributeFilter: ['title']}); element.setAttribute('id', 'foo'); host.appendChild(document.createElement('div')); setTimeout(function () { test.step(function () { assert_equals(eventCount, 2, 'a distinct slotchange event must be enqueued for changes made during a mutation observer delivery'); }); test.done(); }, 0); } testSlotchangeFiresAtEndOfMicroTask('open', true); testSlotchangeFiresAtEndOfMicroTask('closed', true); testSlotchangeFiresAtEndOfMicroTask('open', false); testSlotchangeFiresAtEndOfMicroTask('closed', false); </script> </body> </html>