Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[scoped-custom-element-registry] Fix attributeChangedCallback calling for parser crated elements and toggleAttribute #583

Merged
merged 17 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/scoped-custom-element-registry/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- formAssociated set by first name's defining value or if
CustomElementRegistryPolyfill.formAssociated set contains name

### Fixed

- parser created custom elements call attributeChangedCallback for parser
created attributes

- toggleAttribute called only when attribute value changes

## [0.0.9] - 2023-03-30

- Update dependencies ([#542](https://github.com/webcomponents/polyfills/pull/542))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ const createStandInElement = (tagName: string): CustomElementConstructor => {
this: HTMLElement,
...args: ParametersOf<CustomHTMLElement['connectedCallback']>
) {
ensureAttributesCustomized(this);
const definition = definitionForElement.get(this);
if (definition) {
// Delegate out to user callback
Expand Down Expand Up @@ -514,6 +515,7 @@ const patchAttributes = (
const setAttribute = elementClass.prototype.setAttribute;
if (setAttribute) {
elementClass.prototype.setAttribute = function (n: string, value: string) {
ensureAttributesCustomized(this);
const name = n.toLowerCase();
if (observedAttributes.has(name)) {
const old = this.getAttribute(name);
Expand All @@ -527,6 +529,7 @@ const patchAttributes = (
const removeAttribute = elementClass.prototype.removeAttribute;
if (removeAttribute) {
elementClass.prototype.removeAttribute = function (n: string) {
ensureAttributesCustomized(this);
const name = n.toLowerCase();
if (observedAttributes.has(name)) {
const old = this.getAttribute(name);
Expand All @@ -543,19 +546,74 @@ const patchAttributes = (
n: string,
force?: boolean
) {
ensureAttributesCustomized(this);
const name = n.toLowerCase();
if (observedAttributes.has(name)) {
const old = this.getAttribute(name);
toggleAttribute.call(this, name, force);
const newValue = this.getAttribute(name);
attributeChangedCallback.call(this, name, old, newValue);
if (old !== newValue) {
attributeChangedCallback.call(this, name, old, newValue);
}
} else {
toggleAttribute.call(this, name, force);
}
};
}
};

// Helper to defer initial attribute processing for parser generated
// custom elements. These elements are created without attributes
// so attributes cannot be processed in the constructor. Instead,
// these elements are customized at the first opportunity:
// 1. when the element is connected
// 2. when any attribute API is first used
// 3. when the document becomes readyState === interactive (the parser is done)
let elementsPendingAttributes: Set<CustomHTMLElement & HTMLElement> | undefined;
if (document.readyState === 'loading') {
elementsPendingAttributes = new Set();
document.addEventListener(
'readystatechange',
() => {
elementsPendingAttributes!.forEach((instance) =>
customizeAttributes(instance, definitionForElement.get(instance)!)
);
},
{once: true}
);
}

const ensureAttributesCustomized = (
instance: CustomHTMLElement & HTMLElement
) => {
if (!elementsPendingAttributes?.has(instance)) {
return;
}
customizeAttributes(instance, definitionForElement.get(instance)!);
};

// Approximate observedAttributes from the user class, since the stand-in element had none
const customizeAttributes = (
instance: CustomHTMLElement & HTMLElement,
definition: CustomElementDefinition
) => {
elementsPendingAttributes?.delete(instance);
if (!definition.attributeChangedCallback) {
return;
}
definition.observedAttributes.forEach((attr: string) => {
if (!instance.hasAttribute(attr)) {
return;
}
definition.attributeChangedCallback!.call(
instance,
attr,
null,
instance.getAttribute(attr)
);
});
};

// Helper to patch CE class hierarchy changing those CE classes created before applying the polyfill
// to make them work with the new patched CustomElementsRegistry
const patchHTMLElement = (elementClass: CustomElementConstructor): unknown => {
Expand Down Expand Up @@ -587,17 +645,17 @@ const customize = (
new definition.elementClass();
}
if (definition.attributeChangedCallback) {
// Approximate observedAttributes from the user class, since the stand-in element had none
definition.observedAttributes.forEach((attr) => {
if (instance.hasAttribute(attr)) {
definition.attributeChangedCallback!.call(
instance,
attr,
null,
instance.getAttribute(attr)
);
}
});
// Note, these checks determine if the element is being parser created.
// and has no attributes when created. In this case, it may have attributes
// in HTML that are immediately processed. To handle this, the instance
// is added to a set and its attributes are customized at first
// opportunity (e.g. when connected or when the parser completes and the
// document becomes interactive).
if (elementsPendingAttributes !== undefined && !instance.hasAttributes()) {
elementsPendingAttributes.add(instance);
} else {
customizeAttributes(instance, definition);
}
}
if (isUpgrade && definition.connectedCallback && instance.isConnected) {
definition.connectedCallback.call(instance);
Expand Down
21 changes: 21 additions & 0 deletions packages/scoped-custom-element-registry/test/Element.test.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
<html>
<body>
<script src="../scoped-custom-element-registry.min.js"></script>

<script>
// Test element for testing attribute processing of parser created
// elements.
customElements.define(
'parsed-el',
class extends HTMLElement {
static observedAttributes = ['a', 'b'];
attributeChanges = [];
attributeChangedCallback(name, old, value) {
this.attributeChanges.push({name, old, value});
}
}
);
const imp = document.createElement('parsed-el');
imp.setAttribute('a', 'ia');
imp.id = 'imperative-parsed-el';
document.body.append(imp);
</script>
<parsed-el id="parsed-el" a="a" b="b"></parsed-el>

<script type="module">
import {runTests} from '@web/test-runner-mocha';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ describe('Element', () => {
const $el = document.createElement(tagName);

$el.setAttribute('foo', 'bar');

expect($el.attributeChanges).to.be.deep.equal([
{name: 'foo', old: null, value: 'bar'},
]);
expect($el.getAttribute('foo')).to.equal('bar');
});

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

$el.removeAttribute('foo');

expect($el.attributeChanges).to.be.deep.equal([
{name: 'foo', old: null, value: ''},
{name: 'foo', old: '', value: null},
]);
expect($el.hasAttribute('foo')).to.be.false;
});

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

$el.setAttribute('foo', '');
$el.toggleAttribute('foo', true);

expect($el.attributeChanges).to.be.deep.equal([
{name: 'foo', old: null, value: ''},
]);
expect($el.hasAttribute('foo')).to.be.true;
});

it('should call attributeChangedCallback for parser created element', () => {
const $el = document.getElementById('parsed-el');
expect($el).to.be.ok;
expect($el.attributeChanges).to.be.deep.equal([
{name: 'a', old: null, value: 'a'},
{name: 'b', old: null, value: 'b'},
]);
});

it('should call attributeChangedCallback for imperative created element while parsing', () => {
const $el = document.getElementById('imperative-parsed-el');
expect($el).to.be.ok;
expect($el.attributeChanges).to.be.deep.equal([
{name: 'a', old: null, value: 'ia'},
]);
});
});
});
4 changes: 4 additions & 0 deletions packages/scoped-custom-element-registry/test/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export const getObservedAttributesTestElement = (
static get observedAttributes() {
return observedAttributeNames;
}
attributeChanges = [];
attributeChangedCallback(name, old, value) {
this.attributeChanges.push({name, old, value});
}
},
});

Expand Down
Loading