5

Scenario:

We have a MutationObserver handler function handler.

In handler, we do some DOM manipulation that would trigger handler again. Conceptually, we would have a reentrant handler call. Except MutationObserver doesn't run in-thread, it will fire after the handler has already finished execution.

So, handler will trigger itself, but through the async queue, not in-thread. The JS debugger seems to know this, it will have itself as an async ancestor in the call stack (i.e. using Chrome).

In order to implement some efficient debouncing of events, we need to detect same; that is, if handler was called as a result of changes triggered by itself.

So how to do?

mutationObserver=new MutationObserver(handler);
mutationObserver.observe(window.document,{
    attributes:true,
    characterData:true,
    childList:true,
    subtree:true
});

var isHandling;
function handler(){
    console.log('handler');

    //  The test below won't work, as the re-entrant call 
    //  is placed out-of-sync, after isHandling has been reset
    if(isHandling){
        console.log('Re-entry!');
        //  Throttle/debounce and completely different handling logic
        return;
    }
    
    isHandling=true;
    
    //  Trigger a MutationObserver change
    setTimeout(function(){
        // The below condition should not be here, I added it just to not clog the 
        // console by avoiding first-level recursion: if we always set class=bar,
        // handler will trigger itself right here indefinitely. But this can be
        // avoided by disabling the MutationObserver while handling.
        if(document.getElementById('foo').getAttribute('class')!='bar'){
            document.getElementById('foo').setAttribute('class','bar');
        }
    },0);
    
    isHandling=false;
}


// NOTE: THE CODE BELOW IS IN THE OBSERVED CONTENT, I CANNOT CHANGE THE CODE BELOW DIRECTLY, THAT'S WHY I USE THE OBSERVER IN THE FIRST PLACE

//  Trigger a MutationObserver change
setTimeout(function(){
  document.getElementById('asd').setAttribute('class','something');
},0);

document.getElementById('foo').addEventListener('webkitTransitionEnd',animend);
document.getElementById('foo').addEventListener('mozTransitionEnd',animend);


function animend(){
    console.log('animend');
    this.setAttribute('class','bar-final');
}
#foo {
    width:0px;
    background:red;
    transition: all 1s;
    height:20px;
}
#foo.bar {
    width:100px;
    transition: width 1s;
}
#foo.bar-final {
    width:200px;
    background:green;
    transition:none;
}
<div id="foo" ontransitionend="animend"></div>
<div id="asd"></div>

Note Our use case comprises of 2 components here; one we will call contents which is any run-of-the-mill web app, with a lot of UI components and interface. And an overlay, which is the component observing the content for changes and possibly doing changes of its own.

A simple idea that is not enough is to just disable the MutationObserver while handling; or, assume every second call to handler as recursive; This does not work in the case illustrated above with the animationend event: the contents can have handlers which in turn can trigger async operations. The two most popular such issues are: onanimationend/oneventend, onscroll.

So the idea of detecting just direct (first-call) recursion is not enough, we need quite literally the equivalent of the call stack view in the debugger: a way to tell if a call (no matter how many async calls later) is a descendant of itself.

Thus, this question is not limited to just MutationObserver, as it necessarily involves a generic way to detect async calls descendent of themselves in the call tree. You can replace MutationObserver with any async event, really.

Explanation of the example above: in the example, the mutationobserver is triggering the bar animation on #foo whenever #foo is not .bar. However, the contents has an transitionend handler that sets #foo to .bar-final which triggers a vicious self-recursion chain. We would like to discard reacting to the #foo.bar-final change, by detecting that it's a consequence of our own action (starting the animation with #foo.bar).

Dinu
  • 1,374
  • 8
  • 21
  • Why not just use a higher order function for debouncing? You don't need to implement the debounce logic in every single function you create. – VLAZ Sep 26 '19 at 14:04
  • 1
    Strictly speaking there's no such thing as re-entry in JavaScript. What you see is just another event loop cycle, specifically the microtask queue cycle. There are no special mechanisms in JavaScript so you simply set a telltale expando property on the element or use a `new WeakSet()` where you add the changed elements and check later. – wOxxOm Sep 26 '19 at 14:05
  • @VLAZ - I'm not quite sure I follow you here. – Dinu Sep 26 '19 at 14:07
  • @wOxxOm - if I understand right what you are saying - I would need to tell though if an element is changed as a result of `handler`, or out of it, or both. What you are saying doesn't seem to add to what `MutationObserver` already does: prepare a batch of changes to process async; I need more than that, I need to know where those modifications originated. What I need is what the debugger seems to do flawlessly: determine if `handler is on the async call stack.` – Dinu Sep 26 '19 at 14:09
  • `handler` will always be on the async task, MutationEvents are microtasks, just like Promises. – Kaiido Sep 26 '19 at 14:15
  • @Kaiido - right, so how do I check that? That `handler` has itself before on the async stack. – Dinu Sep 26 '19 at 14:18
  • What is your practical case where you will do this? Let's say you want to mutate from state A to state B in the handler, then at the second call to handler, you'll already be at state B, then don't react anymore. – Kaiido Sep 26 '19 at 14:20
  • @Kaiido - For a higher level description - I have a two-part app: one part I will call **contents** that is a base fully-functional app, and a part I will call **overlay**, that is basically an editing/manipulation interface and that has a responsive behavior through `MutationObserver` to what the **contents** does. Thus, it will process changes made by **contents** that I will call in-band changes. But in handling them, it can manipulate **contents** generating out-of-band changes that can start a vicious cycle of infinite recursion. – Dinu Sep 26 '19 at 14:28
  • 1
    @Kaiido - So I need to tell apart in-band changes from out-of-band (**overlay** originated ones). The best I can think of is using 2 `MutationObserver`s with 2 callbacks which mutually disconnect while observing (so one observer's handler will disable the other observer while handling). But I was wondering if there may be a more generic way of doing this regardless of the async event generator (we could replace `MutationObserver` with `requestAnimationFrame` or `onanimationend` here and it would still be the same question: how to tell if a handler triggers itself asynchronously. – Dinu Sep 26 '19 at 14:33
  • @Dinu I think you may have got better answers if people didn't need to read comments to understand your true question. – Mason Oct 05 '19 at 14:53
  • @Mason If there is something unclear about my question or it is deceptive in any way, what is it? – Dinu Oct 05 '19 at 16:24
  • @Dinu it looks like you have a specific question about a how to handle a mutation observer a certain way, but it appears that your question is actually more broad and about event handling in general. – Mason Oct 05 '19 at 16:29

3 Answers3

1

One possible workaround for this could be to stop the mutation observer when one mutation is being fired

mutationObserver=new MutationObserver(handler);
mutationObserver.observe(window.document,{
    attributes:true,
    characterData:true,
    childList:true,
    subtree:true
});

//  Trigger a MutationObserver change
document.getElementById('foo').setAttribute('class','bar');
document.getElementById('foo').setAttribute('class','');

function handler(){
    console.log('Modification happend')

        mutationObserver.disconnect();
    //  Trigger a MutationObserver change
    document.getElementById('foo').setAttribute('class','bar');
    document.getElementById('foo').setAttribute('class','');

    mutationObserver.observe(window.document,{
    attributes:true,
    characterData:true,
    childList:true,
    subtree:true
});
}

See the JS fiddle

https://jsfiddle.net/tarunlalwani/8kf6t2oh/2/

Tarun Lalwani
  • 142,312
  • 9
  • 204
  • 265
  • Thanks, we did consider that (see question comments). But we were hoping for a solution to generically detect when a handler is triggering itself; because in our question, `MutationObserver` could be replace with `requestAnimationFrame`, `onanimationend`, even `setTimeout`... the result is the same: creating a stream of async tasks. So just detaching `MutationObserver` only creates an appearance of solving the problem. Example: if the handler sets an animated CSS class on an element that has an `onanimationend` handler, which in turn triggers a mutation... you get the picture. – Dinu Sep 28 '19 at 18:31
  • And it gets even more complicated if using an API to manipulate any sort of UI component: a lot of them use i.e. `setTimeout(...,0)` to stage operations; in this case I am again left with an async call that disabling/enabling the observer will not help with, as the execution is scheduled async. But looking at the stack trace in the browser, this is easily detectable, as JS still traces `handler` as an async call ancestor. – Dinu Sep 28 '19 at 18:46
  • Don't go by the browser stack trace, because browser usually has enhanced debug information that you can't get in javascript itself – Tarun Lalwani Sep 28 '19 at 21:16
  • I know, I was hoping there is some kind of a structure like an "async scope" I could use... I'm pretty sure there needs to be such thing for `await` to work... that would allow setting a variable that would be available in future async tasks. I know it's a fringe use so I'm just fishing for someone that may just know the trick :) – Dinu Sep 28 '19 at 21:30
0

The way I have done this in the past is to create semaphore to flag when an async function has already been called and is waiting execution on the event loop.

Here is a simple example for requestAnimationFrame

import raf from 'raf'
import State from './state'

let rafID

function delayedNotify () {
  rafID = null
  State.notify()
}

export default function rafUpdateBatcher () {
  if (rafID) return // prevent multiple request animation frame callbacks
  rafID = raf(delayedNotify)
}

In this case once you call the function, all future calls will be ignored until the first one is executed. Think of it as an async throttle function

For more complex scenarios then another solution might be this project https://github.com/zeit/async-sema

David Bradshaw
  • 11,859
  • 3
  • 41
  • 70
  • Well ok, but I don't have an 'end' event which is temporal and not linked to the call stack. I also do not want to sack all events that occur in a time frame. I want to sack (or differently handle) only events that occur as an async recursion of my own actions in the handler. Not any other event that results from the underlying observed app. – Dinu Oct 04 '19 at 04:54
  • Then you need to experiment with lifting the semaphore when you event has finished, either with setTimeout 0 or that doesn’t work try setting a data attrib on an HTML element and using mutationObserver to detect when it is removed – David Bradshaw Oct 05 '19 at 08:59
0

From what I gather from reading your comments, if action A triggered action B asynchronously, you want to be able to tell where action A was done (in general, not just in a mutation observer). I don't think there's any trick built into JavaScript to do this like it seems you're looking for, however, if you know exactly how your JavaScript works, you can track this information. Job queues in JavaScript are FIFO by definition, the event loop queue also works this way. That means you can store information corresponding to a specific event in an array at the same time you're doing the action which triggers the event, and be confident that they're getting processed in the same order as the array. Here's an example with your mutation observer.

const
    foo = document.getElementById('foo'),
    mutationQueue = [];

function handler(){
    console.log('handler');
    
    const isHandling = mutationQueue.shift();
    if(isHandling){
        console.log('Re-entry!');
        //  Throttle/debounce and completely different handling logic
        return;
    }
    
    setTimeout(()=> {
        foo.setAttribute('class','bar');
        foo.setAttribute('class','');
        mutationQueue.push(true);
    }, 1000 * Math.random());
}

mutationObserver=new MutationObserver(handler);
mutationObserver.observe(foo, {
    attributes:true,
    characterData:true,
    childList:true,
    subtree:true
});

function randomIntervals() {
    setTimeout(()=>{
        foo.setAttribute('class','bar');
        foo.setAttribute('class','');
        mutationQueue.push(false);
        randomIntervals();
    }, 1000 * Math.random())
}

randomIntervals();
<div id='foo'></div>

You have to make sure you add the appropriate value to the array at every point in your code that is going to trigger your async handler, or the entire thing will be messed up. I've never done this myself, I just thought of it for this question, but it seems pretty easy to do wrong. However, I fear this may be the only way to do what you want in general.

Mason
  • 738
  • 7
  • 18
  • Um, after the first `handler()` call, your mutationQueue will forever be `[true]`, as the first `false` is popped and no other value is ever pushed into it... – Dinu Oct 05 '19 at 16:23
  • @Dinu I just copied your code, I figured you were going to be doing something in the `if (isHandling)` section that would make it usable. If no you could just make it an `if...else` statement. – Mason Oct 05 '19 at 16:26
  • I mean, when is `isHandling` ever not true? With your code, every second invocation of `handler`, regardless where it's invoked from. – Dinu Oct 05 '19 at 16:27
  • @Dinu isn't that the behaviour you'd want? What you want is to be able to tell when the changes were made inside the `handler` function, no? In your example code `handler` makes it's own changes every time changes are made from outside the handler. – Mason Oct 05 '19 at 16:31
  • Also, I missed that `return;` statement, that's why I suggested `if...else`, forget I said that. – Mason Oct 05 '19 at 16:33
  • @Dinu I'm confident this approach will work, if you give me a specific example to run in a snippet I will change it to that. – Mason Oct 05 '19 at 16:39
  • Well, your code seems to work only because, only in this very limited example, every second call to handler is a recursive call. It doesn't need a queue at all, you could have just used a counter and tested for #even calls... This is synonimous to other answers that suggest disabling the observer while handling our our own version of using 2 mutation observers in tandem. However, `handler` is not always called in this sequence; if there is any intermediate async step in the process (e.g. `handler` being called as a result of a `animationend` event changing the DOM), it won't work. – Dinu Oct 05 '19 at 16:41
  • See also https://stackoverflow.com/questions/58118551/check-for-async-function-re-entry-in-js/58249990?noredirect=1#comment102682389_58149007 : I hope this explanation will also make it clear why this question about `MutationObserver` is necessarily about async events in general; detecting direct 1st level recursion is not sufficient. – Dinu Oct 05 '19 at 16:43
  • Yes, an `animationend` event in the underlying interface I'm observing can (and almost always will, it's the main reason for an `animationend` event) alter the DOM, thus triggering a chain reaction. Also a lot of UI components use `setTimeout(0)` in their own event handlers to stage events, so if in the `handler` I trip any *contents* handler (such as `onscroll` comes to mind), there's a high chance that will bring on async consequences (as `onscroll` is almost always debounced). – Dinu Oct 05 '19 at 16:49
  • @Dinu I just changed it to show it working with tons of random async thrown in. – Mason Oct 05 '19 at 17:06
  • I cannot add `mutationQueue.push(true);` to the (virtual) `setTimeout` code; that is set by the _content_ app; If I could change all the `content` app wherever it was doing any DOM changes, I wouldn't need the observer in the first place. I would just call `handler()` directly from there. But alas, I can't; the _content_ already exists and has its handlers in place that can trigger async events. – Dinu Oct 05 '19 at 17:11
  • So to rephrase, the async code you added in `handler` in `setTimeout` (the representation is correct) will not be in `handler`, but is code that already exists in the _content_, such as `animationend`, `onscroll` and all kind of other events and that I can't all change. So it will NOT contain your required flag-setting code. I need to detect this behavior by just the code outside the `setTimeout` alone. – Dinu Oct 05 '19 at 17:15
  • I updated the question with a more clear example (and that also won't clog your console). – Dinu Oct 05 '19 at 18:14