Open jcf120 opened 8 years ago
mmm definitely doable, although the mutation observer API isn't the friendliest thing in the world.
Possible thoughts:
a promisified version of requestAnimationFrame with an optional timeout/deadline parameter, perhaps useful in render dependant code
Promise: function nextframe(timeout< Number: optional >)
Doom.nextframe().then(function () {
console.log("will render!");
})
DOM mutation events based on the custom elements v1 spec
Doom.create({
connectedCallback: function () {
console.log(this, "inserted into DOM");
},
disconnectedCallback: function () {
console.log(this, "removed from DOM");
},
// list of attribute names that trigger attributeChangedCallback
observedAttributes: ["disabled", "partymode"],
attributeChangedCallback: function (attrName, oldVal, newVal) {
console.log(this, "changed attribute");
}
});
alternatively we could have something a little simpler like onelementinserted
, onelementremoved
, onelementattributechanged
or perhaps a subproperty called dom events ( which might allow things to be a little tidier in terms of the mutationobserver creation )
I can't guarantee that the element will be inserted into the DOM, so nextframe
might be a little inefficient. Nice though.
Sadly custom elements don't look so well supported yet...
Slight complication with the mutation observer: suppose the element is to be indirectly connected to the document, i.e. it already has a parent, that isn't yet connected. In the mutation, we'd have to test the children of the added node too. Maybe that's not too costly.
well my thought on nextframe was more that after being inserted or changing a property you could call next frame to execute render dependent code. For instance if you needed the next css frame to execute before doing something.
As for the custom elements I wasn't actually suggesting using custom elements, just filching the method names for the sake of continuity. We would still use mutation observers underneath.
As for the "parent can only watch child" issue, that's kinda tough I didn't realize they only watched down the tree(pretty crappy really). Presuming we just said screw it and only watch the document body even then we'd be replicating the reason the original mutation events were depreciated; every child insert/removal is VERY expensive, document.innerHTML = "hello"
for instance may produce 5000 removal events and 1 add event... Which we'd have to filter for our one useful event!
Having a quick look around and a think the potential options (and issues)are:
Ha yes, guess we should avoid that!
Hadn't noticed modify
's removeChild
until you drew my attention to it just now. Nice. Yeah, it seems a bit much to ask the dev to do everything through Doom. Easy to slip up.
I suppose the real answer to my problem is to structure my UI logic better, such that it knows when it's presented. That in combination with your requestAnimationFrame
note.
There's also HTMLElement Doom.remove(HTMLElement element, Number delay <optional>)
which is quite useful. But yeah anyway in terms of asking the developer to use Doom in their own code it isn't too much of an ask... but other libraries they use won't be using Doom methods. So yeah that plan doesn't exactly fly!
So I guess the only guaranteed way is polling, but we could use a mix to get a slight performance boost for Doom methods I guess?
Okay probably shouldn't be pasting this in the issue but whatever, written a chunk of code that allows you to register connection and disconnection events for an element. Requires the developer to be a little careful, as it has to keep references to elements internally... so leaving events registered to dead elements will leak all over the place. Not much we can do about that though.
Implementation uses only the poll method, so should be fine with people not using Doom methods for moving stuff around. Does have a slight flaw that if you move an element around more than once during the poll interval (16ms, so SHOULD be roughly once per frame) only one event will be called.
Anyway, what are your thoughts?
var observedElements = [];
var timerRef = null;
/*
Returns our status obect, which is a snapshot of the element at the
last update. If it does not exist yet, and shouldCreate is set then it
will be created. Will start the timer if it isn't running yet.
*/
function getstatus (element, shouldCreate) {
var i = 0;
var l = observedElements.length;
var status = null;
for (; i < l; i++)
{
if (observedElements[i].element === element) {
status = observedElements[i];
break;
}
}
if (!status && shouldCreate) {
status = {
element: element,
parent: element.parentNode,
connectHandler: [],
disconnectHandler: []
};
observedElements.push(status);
start();
}
return status;
}
/*
Adds a new connect callback to the current element, also prevents the same
handler being registered twice
*/
function addConnectionHandler (element, callback) {
var status = getstatus(element, true);
if (status.connectHandler.indexOf(callback) === -1)
status.connectHandler.push(callback);
}
/*
Adds a new disconnect callback to the current element, also prevents the same
handler being registered twice
*/
function addDisconnectionHandler (element, callback) {
var status = getstatus(element, true);
if (status.disconnectHandler.indexOf(callback) === -1)
status.disconnectHandler.push(callback);
}
/*
Removes a connect callback from the current element, will destroy the status
object if it's the last handler. Also will stop the status poll if its the
last element being disabled
*/
function removeConnectionHandler (element, callback) {
var status = getstatus(element, false);
if (status) {
var index = status.connectHandler.indexOf(callback);
if (index !== -1) {
if (status.connectHandler.length === 1 && status.disconnectHandler.length === 0) {
ignoreElement(element);
} else {
status.connectHandler.splice(index, 1);
}
}
}
}
/*
Removes a disconnect callback from the current element, will destroy the status
object if it's the last handler. Also will stop the status poll if its the
last element being disabled
*/
function removeDisconnectionHandler (element, callback) {
var status = getstatus(element, false);
if (status) {
var index = status.disconnectHandler.indexOf(callback);
if (index !== -1) {
if (status.connectHandler.length === 0 && status.disconnectHandler.length === 1) {
ignoreElement(element);
} else {
status.disconnectHandler.splice(index, 1);
}
}
}
}
/*
Executes a collection of handlers, with the element as the context. Can pass
an eventObject for extra information.
*/
function fireEvent (element, handlers, eventObject) {
for (var i = 0, l = handlers.length; i < l; i++) {
try {
handlers[i].call(element, eventObject);
} catch (e) {
console.error(e);
}
}
}
/*
Removes an element from the observation list, will stop the timer if its the
last element on the list.
*/
function ignoreElement (element) {
var i = 0;
var l = observedElements.length;
for (; i < l; i++)
{
if (observedElements[i].element === element) {
observedElements.splice(i, 1);
break;
}
}
if (observedElements.length === 0)
stop();
}
/*
Checks for modifications to elements on the observation list and calls the
relevent handlers.
*/
function poll () {
var i = 0;
var l = observedElements.length;
var status = null;
var element = null;
for (; i < l; i++) {
status = observedElements[i];
element = status.element;
if (status.parent !== element.parentNode) {
fireEvent(element, status.disconnectHandler);
if (element.parentNode) {
fireEvent(element, status.connectHandler);
}
status.parent = element.parentNode;
}
}
}
/*
Starts the timer if it isn't running.
*/
function start () {
if (timerRef)
return;
timerRef = setInterval(poll, 16);
}
/*
Stops the timer if it is running.
*/
function stop () {
if (!timerRef)
return;
clearInterval(timerRef);
timerRef = null;
}
Oooo, cool. Some thoughts:
At the moment you're detecting direct parent changes right? Which isn't necessarily the same thing as document (dis)connects. (body.contains
?) I don't easily see how to efficiently handle indirect (dis)connects. We could propagate the observation up through the watched element's parents - sounds expensive though.
You reckon polling once a frame will have less overhead than triggering polls via MutationObserver
? Might not be so bad with your on/off switch.
Yeah I was aiming for the element being added / removed from the parent rather than the parent document changing.
For "indirect" I presume you mean if like the elements parent was inserted into another element? I think generally we should avoid the bubbling the event upward. If you did want to watch the element above change then you should watch the element above instead! Although I do see a use case for seeing if the root element changes. Say an element is inside a document fragment, then the fragment is inserted into the document and we need to update the size or something. Thinking we can use contains
like you sat to detect if the element is still a child during the poll.
Method for getting the root element
function getRoot(element) {
while (element.parentNode) // elements with no parent return null
element = element.parentNode;
return element;
}
Updated status object
status = {
element: element,
parent: element.parentNode,
root: getRoot(element),
connectHandler: [],
disconnectHandler: [],
adoptedHanlder: []
}
Updated poll method, checks to see if the element still is a descendant of its root
function poll () {
var i = 0;
var l = observedElements.length;
var status = null;
var element = null;
for (; i < l; i++) {
status = observedElements[i];
element = status.element;
if (status.root.contains(element) === false) {
fireEvent(element, status.adoptedHandler);
status.root = getRoot(element);
}
if (status.parent !== element.parentNode) {
fireEvent(element, status.disconnectHandler);
if (element.parentNode) {
fireEvent(element, status.connectHandler);
}
status.parent = element.parentNode;
}
}
}
I don't think MutationObservers is suitable for 2 reasons:
document.body
to the observer, and watch for all childNode changes and filter the events for our observed elements. Which involves the browser having to deal with lots of events, with big propagation chains.I think polling will work fairly well, presuming the developer isn't watching every element on the page... It just has to work through a flat list and run some quick checks. Running it at 60Hz uses more execution time than I'd like but we can only reduce it with increased latency. I guess if we're worried about excessive time usage requestAnimationFrame might be more suitable.
I think my original hope/intention has been lost somewhere along the way: A convenient way to know whether something has been attached to the document, so I can execute post-render dependant code. Watching parents was just a means to an end. Document fragments and loose elements aren't rendered, so I'm not really concerned with them.
"If you did want to watch the element above change then you should watch the element above instead!" If I've got to think about which elements need watching, beyond the element with the render specific code, then the convenience is lost. I was hoping to build a contained alloy that would be rendered correctly, without the need to consider where/when it was being used.
Example flow:
"Say an element is inside a document fragment, then the fragment is inserted into the document and we need to update the size or something." You've made me realise that I hadn't been considering reshapes/resizes. I guess that's my real want - a reshape event.
Don't get me wrong, this polling method is neat for what it is, but I'm thinking I proposed a feature before I understood what I needed from it.
I'd recommend not writing anything until I'm sure of what I'm after! I may end up writing a project specific solution, that we can later consider incorporating into Doom if it's worth it.
Sorry if I dashed your hopes and dreams a little ;) I do think this feature has some potential, after all I did base the proposed implementation on the custom element spec which has been heavily developed by the community.
If you still think it's helpful I'm pretty sure the "adoptedCallback" implementation will do exactly what you want, also it's nearly finished tbh. The code I posted on this issue was most of the work, and I've already created a branch to turn it into something useful so not much more work is required.
Doom.create({
style: "width: 50px",
adoptedCallback: function () {
Doom.modify({
element: this,
style: "width: " + this.parentNode.getBoundingClientRect().width + "px"
});
}
});
etc etc
think it needs about 30 minutes to an hour to finish it
Implemented and tested, candidate available in pull request
I've got some code that is dependent on an element having already been rendered in the DOM, but don't necessarily know when it will be be inserted. I thought it might be handy to have DOM manipulation events. E.g.:
What d'ya reckon?