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

Update A-Frame to custom elements v1 API #5136

Merged
merged 1 commit into from
Oct 25, 2022

Conversation

dmarcos
Copy link
Member

@dmarcos dmarcos commented Oct 13, 2022

A-Frame is still using custom elements v0 but always running through polyfill since the old API has been deprecated.

This modernizes the code base and makes use of the native APIs shipped in browsers today. Polyfill only kicks in for browser without an implementation of custom elements v1 API.

This will help us to better reason about #5124 and address the root cause.

This PR unifies behavior of all A-Frame defined HTML elements. All connectedCallback associated logic is deferred until DOMContentLoaded fires. This should eliminate the problems with entity inter-dependencies (e.g I want to query an entity from the init method of a component or system that might not have yet been initialized).

@dmarcos dmarcos changed the title Updating A-Frame to custom elements v1 API Update A-Frame to custom elements v1 API Oct 13, 2022
@arpu
Copy link
Contributor

arpu commented Oct 13, 2022

better use https://github.com/ungap/custom-elements#readme ?

Warning: [@ungap/custom-elements](https://github.com/ungap/custom-elements#readme) is what you are looking for!
on https://www.npmjs.com/package/@webreflection/custom-elements

}

doConnectedCallback () {
ANode.prototype.connectedCallback.call(this);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's wrong with:

super.connectedCallback();

? 🤔

src/index.js Outdated
// Custom Elements v1 polyfill
if (!window.customElements) {
const customElementsPolyfill = require('@webreflection/custom-elements');
customElementsPolyfill(window || global);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you check for !window.customElements without checking if window exists at all, so this will never pass through the ||, or better, code will fail anyway if window is not around. I suggest to either remove that || global, as never reached, and maybe use self instead of window for Worker cases, or even better: globalThis.

That being said, you could use the proper, maintained, polyfill instead.

@@ -11,6 +11,8 @@ suite('node acceptance tests', function () {
global.navigator = _window.navigator;
global.document = _window.document;
global.screen = {};
global.HTMLElement = _window.Element;
global.window.customElements = {define: function () {}};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI LinkeDOM has customElements too

@dmarcos
Copy link
Member Author

dmarcos commented Oct 14, 2022

@kylebakerio Does this PR fix the issue with the camera?


this.addToParent();
AEntity.prototype.doConnectedCallback.call(this);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use simply this.doConnectedCallback() here right?

if (this.isScene) { return; }
doConnectedCallback () {
var assetsEl; // Asset management system element.
var sceneEl = this.sceneEl;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this thing work?
From my understanding, this.sceneEl is defined when executing ANode connectedCallback() but you call it after here.

Copy link
Contributor

@vincentfretin vincentfretin Oct 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I confirm, I just added a console.log, this.sceneEl is always undefined here. If you put super.connectedCallback(); above, it will contain the scene element.

connectedCallback () {
// Defer if DOM is not ready.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.connectedCallback.bind(this));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, you wanted to write this.doConnectedCallback.bind(this) right?

if (this.timeout) { clearTimeout(this.timeout); }
}
},
ANode.prototype.connectedCallback.call(this);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simply super.connectedCallback()

connectedCallback () {
// Defer if DOM is not ready.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.connectedCallback.bind(this));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why you created a doConnectedCallback function if you don't use it here. The second time connectedCallback is called, we know the condition is false, so you intended do use this.doConnectedCallback.bind(this) here to not evaluate the condition again, right?

Copy link
Member Author

@dmarcos dmarcos Oct 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's effectively the same but this way when read it's more clear to see that we're just deferring the call. Don't have to read understand doConnectedCallback

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

evaluating the condition only happens once so perf-wise is negligible over more clear code

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your logic, if it's more clear to you to see that we're just deferring the call, and I can agree with that, then you don't even need to create a separate function doConnectedCallback, you can just put the code inside connectedCallback after the condition, unless I'm missing something why you created it. That's why it bothered me reading this code, when I have this pattern I usually create a function like this to use it as the callback, mostly important when the condition is costly so it's not executed a second time. Here I agree the condition cost is negligible.

To me, the condition is evaluated twice when the readyState is loading:

  • connectedCallback called
  • if (document.readyState === 'loading') evaluated to true
  • document.addEventListener('DOMContentLoaded', this.connectedCallback.bind(this)); callback registered
  • connectedCallback called because of the DOMContentLoaded event
  • if (document.readyState === 'loading') evaluated to false
  • doConnectedCallback called

in comparison, using doConnectedCallback as the callback:

  • connectedCallback called
  • if (document.readyState === 'loading') evaluated to true
  • document.addEventListener('DOMContentLoaded', this.doConnectedCallback.bind(this)); callback registered
  • doConnectedCallback called because of the DOMContentLoaded event

Copy link
Member Author

@dmarcos dmarcos Oct 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created it for readability. I like small focused functions when possible

Copy link
Member Author

@dmarcos dmarcos Oct 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even if evaluated twice cost is negligible and it happens before the scene loads. Dynamically added entities once the DOM has loaded won't defer the call.

connectedCallback () {
// Defer if DOM is not ready.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.connectedCallback.bind(this));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use doConnectedCallback here

disconnectedCallback () {
// Remove from scene index.
var sceneIndex = scenes.indexOf(this);
super.disconnectedCallback.call(this);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simply super.disconnectedCallback();

return;
}
// ANode method.
super.connectedCallback();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line should really be at the top.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmarcos there is actually no issue here, but a rather confusing code. I actually didn't see you do this.sceneEl twice here. You do this here:

var sceneEl = this.sceneEl; // but we know it's actually undefined because we didn't call super.connectedCallback()
...
super.connectedCallback();
...
sceneEl = this.sceneEl;

so I'd suggest just to use

var sceneEl;
...
super.connectedCallback();
...
sceneEl = this.sceneEl;

or

super.connectedCallback();
var sceneEl = this.sceneEl;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you chose the first suggestion, great.

for (componentName in this.components) {
this.removeComponent(componentName, false);
}
super.disconnectedCallback();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't have this call before. We call it a second time below.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the previous code, putting a console.log in detachedCallback and removing an a-entity element via the chrome inspector, it was called twice, I'm not sure why twice.
Here in the new code, this is called four times. Removing this line, disconnectedCallback is called twice, no error, tests are all passing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I removed the duplicated call but don't see disconnectedCallback called twice. Can you double check or share steps to reproduce? Thanks

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add a console.log exactly where you just removed the line, open the inspector, right click on the node and remove. Anyway it's the same behavior than in the previous version now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I just understood why I have it twice, I was removing a-entity that has a child a-camera, so I'm removing two entities at the same time that's why.

}
},
this.removeFromParent();
super.disconnectedCallback.call(this);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simply super.disconnectedCallback();

return element;
});
observer.observe(this, observerConfig);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is similar to what we had in the polyfill https://github.com/dmarcos/document-register-element/blob/cd665644367686d5645048f853d22fef9584bbf0/src/document-register-element.js#L529-L560 right? Previously we had a single MutationObserver instance for the whole document, here we have a MutationObserver instance for each node. Do we have to worry about performance here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too familiar about how the old polyfill worked. Not sure about the overhead of an observer. Any ideas on how to do it with one are welcome. it's a bit unfortunate that customElements V1 observed attributes can only be declared statically.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with it either, it was actually the first time I saw a MutationObserver object myself. Not sure about the performance really, I was just reading the comment about perf above the code I linked, this comment https://github.com/dmarcos/document-register-element/blob/cd665644367686d5645048f853d22fef9584bbf0/src/document-register-element.js#L526-L528

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you check WebKit source code around MO you realize having one or many doesn't make much of a difference.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @WebReflection for checking that. So we're good then.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @vincentfretin and @WebReflection for the feedback

@kylebakerio
Copy link
Contributor

@kylebakerio Does this PR fix the issue with the camera?

Tested it. seems it does.

this.setAttribute('screenshot', '');
this.setAttribute('vr-mode-ui', '');
this.setAttribute('device-orientation-permission-ui', '');
super.connectedCallback();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that in previous code, the super method was called before setting the default components. This super method should be called at the top, an example super.connectedCallback(); is initializing (currently wrongly) this.sceneEl, and the component init may use this.sceneEl but it's probably not the case here.

@dmarcos
Copy link
Member Author

dmarcos commented Oct 17, 2022

@kylebakerio Does this PR fix the issue with the camera?

Tested it. seems it does.

Thanks so much for taking the time to test it. Much appreciated.

@@ -1,6 +1,8 @@
// Polyfill `Promise`.
window.Promise = window.Promise || require('promise-polyfill');

require('@ungap/custom-elements');

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WebReflection with this statement the polyfill is a pass-through if custom elements API is defined? Just following the how-to: https://github.com/ungap/custom-elements#how-to

Thanks for the feedback

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the poly includes features detection and leave untouched browsers that implement latest status of the specs:

  • custom elements builtin extends (only safari / webkit gets fixed)
  • customElements.whenDefined('some-comp').then(Class => { ... }) is patched for older browsers that don't pass the related class to the promise once the definition is registered

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @WebReflection. Just to double check. We're good just requiring the polyfill, are we? No other checks or initialization necessary?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the poly includes features detection for all cases, including jurassic one where customElements is not a thing ... if you want to rise your target browsers, the Safari/WebKit only is an option but it requires manual feature detection ... happy to help with that though

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P.S. if you've fully switched to customElements without builtin extends support, that might even not be needed

@vincentfretin
Copy link
Contributor

Two important issues I commented that are not about code style #5136 (comment) and #5136 (comment) are not addressed yet, see my proposed changes here dmarcos#6

@@ -39,7 +39,7 @@ class ANode extends HTMLElement {
document.addEventListener('DOMContentLoaded', this.connectedCallback.bind(this));
return;
}
ANode.prototype.doConnectedCallback.call(this);
this.doConnectedCallback();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was wrong, we need to put back the previous line.
I get this error with the change:

a-entity.js:73 Uncaught RangeError: Maximum call stack size exceeded
    at a.doConnectedCallback (a-entity.js:73:3)
    at a.connectedCallback (a-node.js:42:10)
    at a.doConnectedCallback (a-entity.js:75:11)
    at a.connectedCallback (a-node.js:42:10)
    at a.doConnectedCallback (a-entity.js:75:11)
    at a.connectedCallback (a-node.js:42:10)
    at a.doConnectedCallback (a-entity.js:75:11)
    at a.connectedCallback (a-node.js:42:10)
    at a.doConnectedCallback (a-entity.js:75:11)
    at a.connectedCallback (a-node.js:42:10)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a-scene, a-assets, a-mixin, we can use this.doConnectedCallback(); because we don't inherit from those.
But for a-node and a-entity, you can't use it, because it will call the doConnectedCallback from the derived class, we don't want that here.

@vincentfretin
Copy link
Contributor

I'm currently using a build with my changes in my project. I'll let you know if I find any other issue while I'm developing on it.
Currently it's working great on Firefox and Chrome on Ubuntu, Chrome Android, Safari iPad where I'm testing usually.

@dmarcos
Copy link
Member Author

dmarcos commented Oct 24, 2022

I think this is close to ready. If you give me your blessing I'll go ahead and merge.

@vincentfretin
Copy link
Contributor

There is just one last confusing code here https://github.com/aframevr/aframe/pull/5136/files#r1003525392 otherwise good to merge for me.

@dmarcos
Copy link
Member Author

dmarcos commented Oct 25, 2022

All right! Merging. This PR is a scary one 😱 🤞 Perfect for Halloween. Thanks everybody for the feedback. Much much appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants