Skip to content

Commit 794d56d

Browse files
authored
[scoped-custom-element-registry] Fix attributeChangedCallback calling for parser crated elements and toggleAttribute (#583)
- parser created custom elements call attributeChangedCallback for parser created attributes - toggleAttribute called only when attribute value changes
1 parent bcdc52b commit 794d56d

File tree

5 files changed

+129
-15
lines changed

5 files changed

+129
-15
lines changed

packages/scoped-custom-element-registry/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- formAssociated set by first name's defining value or if
2424
CustomElementRegistryPolyfill.formAssociated set contains name
2525

26+
### Fixed
27+
28+
- parser created custom elements call attributeChangedCallback for parser
29+
created attributes
30+
31+
- toggleAttribute called only when attribute value changes
32+
2633
## [0.0.9] - 2023-03-30
2734

2835
- Update dependencies ([#542](https://github.com/webcomponents/polyfills/pull/542))

packages/scoped-custom-element-registry/src/scoped-custom-element-registry.ts

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ const createStandInElement = (tagName: string): CustomElementConstructor => {
416416
this: HTMLElement,
417417
...args: ParametersOf<CustomHTMLElement['connectedCallback']>
418418
) {
419+
ensureAttributesCustomized(this);
419420
const definition = definitionForElement.get(this);
420421
if (definition) {
421422
// Delegate out to user callback
@@ -514,6 +515,7 @@ const patchAttributes = (
514515
const setAttribute = elementClass.prototype.setAttribute;
515516
if (setAttribute) {
516517
elementClass.prototype.setAttribute = function (n: string, value: string) {
518+
ensureAttributesCustomized(this);
517519
const name = n.toLowerCase();
518520
if (observedAttributes.has(name)) {
519521
const old = this.getAttribute(name);
@@ -527,6 +529,7 @@ const patchAttributes = (
527529
const removeAttribute = elementClass.prototype.removeAttribute;
528530
if (removeAttribute) {
529531
elementClass.prototype.removeAttribute = function (n: string) {
532+
ensureAttributesCustomized(this);
530533
const name = n.toLowerCase();
531534
if (observedAttributes.has(name)) {
532535
const old = this.getAttribute(name);
@@ -543,19 +546,74 @@ const patchAttributes = (
543546
n: string,
544547
force?: boolean
545548
) {
549+
ensureAttributesCustomized(this);
546550
const name = n.toLowerCase();
547551
if (observedAttributes.has(name)) {
548552
const old = this.getAttribute(name);
549553
toggleAttribute.call(this, name, force);
550554
const newValue = this.getAttribute(name);
551-
attributeChangedCallback.call(this, name, old, newValue);
555+
if (old !== newValue) {
556+
attributeChangedCallback.call(this, name, old, newValue);
557+
}
552558
} else {
553559
toggleAttribute.call(this, name, force);
554560
}
555561
};
556562
}
557563
};
558564

565+
// Helper to defer initial attribute processing for parser generated
566+
// custom elements. These elements are created without attributes
567+
// so attributes cannot be processed in the constructor. Instead,
568+
// these elements are customized at the first opportunity:
569+
// 1. when the element is connected
570+
// 2. when any attribute API is first used
571+
// 3. when the document becomes readyState === interactive (the parser is done)
572+
let elementsPendingAttributes: Set<CustomHTMLElement & HTMLElement> | undefined;
573+
if (document.readyState === 'loading') {
574+
elementsPendingAttributes = new Set();
575+
document.addEventListener(
576+
'readystatechange',
577+
() => {
578+
elementsPendingAttributes!.forEach((instance) =>
579+
customizeAttributes(instance, definitionForElement.get(instance)!)
580+
);
581+
},
582+
{once: true}
583+
);
584+
}
585+
586+
const ensureAttributesCustomized = (
587+
instance: CustomHTMLElement & HTMLElement
588+
) => {
589+
if (!elementsPendingAttributes?.has(instance)) {
590+
return;
591+
}
592+
customizeAttributes(instance, definitionForElement.get(instance)!);
593+
};
594+
595+
// Approximate observedAttributes from the user class, since the stand-in element had none
596+
const customizeAttributes = (
597+
instance: CustomHTMLElement & HTMLElement,
598+
definition: CustomElementDefinition
599+
) => {
600+
elementsPendingAttributes?.delete(instance);
601+
if (!definition.attributeChangedCallback) {
602+
return;
603+
}
604+
definition.observedAttributes.forEach((attr: string) => {
605+
if (!instance.hasAttribute(attr)) {
606+
return;
607+
}
608+
definition.attributeChangedCallback!.call(
609+
instance,
610+
attr,
611+
null,
612+
instance.getAttribute(attr)
613+
);
614+
});
615+
};
616+
559617
// Helper to patch CE class hierarchy changing those CE classes created before applying the polyfill
560618
// to make them work with the new patched CustomElementsRegistry
561619
const patchHTMLElement = (elementClass: CustomElementConstructor): unknown => {
@@ -587,17 +645,17 @@ const customize = (
587645
new definition.elementClass();
588646
}
589647
if (definition.attributeChangedCallback) {
590-
// Approximate observedAttributes from the user class, since the stand-in element had none
591-
definition.observedAttributes.forEach((attr) => {
592-
if (instance.hasAttribute(attr)) {
593-
definition.attributeChangedCallback!.call(
594-
instance,
595-
attr,
596-
null,
597-
instance.getAttribute(attr)
598-
);
599-
}
600-
});
648+
// Note, these checks determine if the element is being parser created.
649+
// and has no attributes when created. In this case, it may have attributes
650+
// in HTML that are immediately processed. To handle this, the instance
651+
// is added to a set and its attributes are customized at first
652+
// opportunity (e.g. when connected or when the parser completes and the
653+
// document becomes interactive).
654+
if (elementsPendingAttributes !== undefined && !instance.hasAttributes()) {
655+
elementsPendingAttributes.add(instance);
656+
} else {
657+
customizeAttributes(instance, definition);
658+
}
601659
}
602660
if (isUpgrade && definition.connectedCallback && instance.isConnected) {
603661
definition.connectedCallback.call(instance);

packages/scoped-custom-element-registry/test/Element.test.html

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
<html>
22
<body>
33
<script src="../scoped-custom-element-registry.min.js"></script>
4+
5+
<script>
6+
// Test element for testing attribute processing of parser created
7+
// elements.
8+
customElements.define(
9+
'parsed-el',
10+
class extends HTMLElement {
11+
static observedAttributes = ['a', 'b'];
12+
attributeChanges = [];
13+
attributeChangedCallback(name, old, value) {
14+
this.attributeChanges.push({name, old, value});
15+
}
16+
}
17+
);
18+
const imp = document.createElement('parsed-el');
19+
imp.setAttribute('a', 'ia');
20+
imp.id = 'imperative-parsed-el';
21+
document.body.append(imp);
22+
</script>
23+
<parsed-el id="parsed-el" a="a" b="b"></parsed-el>
24+
425
<script type="module">
526
import {runTests} from '@web/test-runner-mocha';
627

packages/scoped-custom-element-registry/test/Element.test.html.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ describe('Element', () => {
119119
const $el = document.createElement(tagName);
120120

121121
$el.setAttribute('foo', 'bar');
122-
122+
expect($el.attributeChanges).to.be.deep.equal([
123+
{name: 'foo', old: null, value: 'bar'},
124+
]);
123125
expect($el.getAttribute('foo')).to.equal('bar');
124126
});
125127

@@ -131,7 +133,10 @@ describe('Element', () => {
131133
const $el = getHTML(`<${tagName} foo></${tagName}>`);
132134

133135
$el.removeAttribute('foo');
134-
136+
expect($el.attributeChanges).to.be.deep.equal([
137+
{name: 'foo', old: null, value: ''},
138+
{name: 'foo', old: '', value: null},
139+
]);
135140
expect($el.hasAttribute('foo')).to.be.false;
136141
});
137142

@@ -148,8 +153,27 @@ describe('Element', () => {
148153

149154
$el.setAttribute('foo', '');
150155
$el.toggleAttribute('foo', true);
151-
156+
expect($el.attributeChanges).to.be.deep.equal([
157+
{name: 'foo', old: null, value: ''},
158+
]);
152159
expect($el.hasAttribute('foo')).to.be.true;
153160
});
161+
162+
it('should call attributeChangedCallback for parser created element', () => {
163+
const $el = document.getElementById('parsed-el');
164+
expect($el).to.be.ok;
165+
expect($el.attributeChanges).to.be.deep.equal([
166+
{name: 'a', old: null, value: 'a'},
167+
{name: 'b', old: null, value: 'b'},
168+
]);
169+
});
170+
171+
it('should call attributeChangedCallback for imperative created element while parsing', () => {
172+
const $el = document.getElementById('imperative-parsed-el');
173+
expect($el).to.be.ok;
174+
expect($el.attributeChanges).to.be.deep.equal([
175+
{name: 'a', old: null, value: 'ia'},
176+
]);
177+
});
154178
});
155179
});

packages/scoped-custom-element-registry/test/utils.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export const getObservedAttributesTestElement = (
4343
static get observedAttributes() {
4444
return observedAttributeNames;
4545
}
46+
attributeChanges = [];
47+
attributeChangedCallback(name, old, value) {
48+
this.attributeChanges.push({name, old, value});
49+
}
4650
},
4751
});
4852

0 commit comments

Comments
 (0)