Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Apply/Replay MutationRecord changes (MutationObserver API)

I've attached a MutationObserver to a DOM and am monitoring for changes.

I get the notification and receive a MutationRecord object containing a description of what was changed.

Is there a supported/standard/easy way to apply the changes in MutationRecord again? In other words, use the MutationRecord object to "replay" changes to the DOM?

Any help appreciated!

like image 467
Andy Hin Avatar asked Jan 31 '26 08:01

Andy Hin


1 Answers

I couldn't help myself, after noticing that this was asked 6 years ago in 2016, so I made a "DomRecorder Lite". This program records three types on DOM mutation events that can happen inside a target element, in this case some div. You can play around with the tool and try to:

  • Add elements
  • Change text of a selected element
  • Remove selected element
  • Replay selected action
  • Replay all actions

There might be a bug or two, but I think that the Proof of Concept is there either way.

I also made a small video demonstrating how the program works (when I press edit-button, a prompt asking for a text appears, but that isn't recorded): https://www.veed.io/view/7325e363-ac5c-4c9b-8138-cd990b253372


We use two custom classes called MutationEvent and DOMRecorder:

MutationEvent

MutationEvent is responsible for storing information about an observer mutation event, such as what the event was (TEXTCHANGED | INSERT | REMOVE), DOM element data related to the event and just generic data, in case we need to attach some extra data to an event.

MutationEvent is also responsible for replaying itself depending on what the event type is. I made this PoC to support three types of events as written above.

The INSERT case is somewhat interesting because of the user's ability to replay one event (doesn't clear the recorded wrapper element) or to replay all events (clears the recorded wrapper element first).

When replaying only one INSERT event, we can't directly insert the element saved to the MutationEvent, because it would have the same id in DOM as the element from which this event originates in the first place, so we create this sort of "actor element", which is a clone of the original element (and its child content), but with a different id.

If we are replaying all the events instead, the recorded wrapper element is cleared first, which means we don't have to worry about this issue.

case 'INSERT':
  elem = document.querySelector(`#${$targetid}`);
  if(elem) {
    let actorElement = null;
    
    if(clean) {
      actorElement = this.element.cloneNode(true);
      actorElement.id = 'd-' + Math.random().toString(16).slice(2, 8);
    }
    else {
      actorElement = this.element;
    }
    elem.append(actorElement);
    success = true;
  }
  break;

DOMRecorder

DOMRecorder is kind of the meat and potatoes of everything. Its main resposibility is to use MutationObserver to observe changes in the DOM of the recorded element and to create MutationEvents from those changes. Other resposibilities include replaying a single or every MutationEvent it has stored. In this PoC, the DOMRecorder is also responsible for updating some of the select inputs on the page, which is probably not 100% legitimate way to separate concerns, but that's not relevant here.


Again, this is just a proof of concept how replaying of MutationEvents could be implemented, so there are only a limited number of supported events. This PoC also doesn't take a position regarding deep DOM subtree modifications, but after coding this, I am not too scared to think of it. see edit

Is there a supported/standard/easy way

Probably not. Can't think of many uses for such standard at least. There are some libraries out there that record user's interactions on a webpage, but those don't need to record/replay actual DOM modifications, only the interactions that could result in DOM modifications.

Should also keep in mind that, in general, it's a lot easier to implement a system that watches for changes and emits some kind of change events telling you that "this just changed", than it is to implement a system that is able to re-create those changes. The former doesn't really need to care about anything other than changes happening anywhere in any form, but with the latter, there are a lot of considerations when it comes to re-creation.

To list some:

  • Should you re-create something that should be unique? Is it really a replay, if the replayed changes are not exactly the same as the recorded ones?
  • What if the "replay" doesn't start from the same state as the "recording" started?
  • Should you also record what the replay does?
  • How do you handle local relations between events?
  • How do you handle global relations between events and the DOM?
  • If I make an element, change it's text, remove the element and want to replay the text change event again, what should happen?
  • What should happen when a parent of deeply nested element gets removed or changed in some way that it can no longer hold the nested element?
  • If we decided that deletion of a parent of deeply nested element should remove the whole element branch, should that be an event of itself, or perhaps even multiple removal events for each obsolete child on the branch?
  • How to keep track of CSS changes, after trying this out, it is not as simple as it seems

EDIT

So I added events for subtree insertion and removal. This means that one can now add child elements inside other elements. Functions that worked for the top-level elements should work for any child element as well, or child-of-a-child, or child-of-a-child-of-a-child, ...

subtree

This was a bit tricky to implement and the biggest issue here was keeping tracking of parent element ids. See, when an element is created, some id will be generated for it. To insert an element inside of another existing element, we obviously need to know its id. Now this seems like a no problem at first, the user selected parent element has some id, so we just get it and insert the element inside.

Everything seems to work, but we run into a problem when replaying the events and the subtree insertion just won't do anything. The reason for this was that when the program replays the events, specifically the SUBINSERT event, it uses a wrong id for the parent element. This is because the replay function clears the recorded wrapper before starting the replay and therefore all the elements created during replay will get new ids, so the SUBINSERT will not know what the parent element id is during the replay.

To fix this, I added a second, "static internal id" for each event:

constructor(recorderInstance, event, element, data)
{
    this.static_replay_id = getId();
    ...
}

This id will not change from recording to replay, so when we want to insert an element as a child element to some parent element, we do the following event-wise:

  1. Get the id of the mutation.target, which is the id of the parent element
  2. Assume that since this parent element exists, there must also exist an event for it already
  3. Get the static_replay_id of the event by the mutation.target id
  4. Add the static_replay_id as subtree_target for this SUBINSERT event

getStaticIdForMutationTarget:

getStaticIdForMutationTarget(targetid)
{
    return this.events.find((evt) => evt.element.id === targetid).static_replay_id;
}

And when we replay the SUBINSERT event, we get the current DOM id of the parent element by the saved static_replay_id:

elem = document.querySelector(`#${$targetid}div#${this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)}`);

getEventTargetByStaticReplayId:

getEventTargetByStaticReplayId(replay_id)
{
  let targetid = null;
  
  for(let i = 0; i < this.events.length; i++) {
    let evt = this.events[i];
    
    if(!"static_replay_id" in evt.data)
      continue;
    
    if(evt.static_replay_id === replay_id) {
      targetid = evt.element.id;
      break;
    }
  }
  
  return targetid;
}

EDIT 2

Added support for applying and replaying inline CSS style events and changed long event name strings, such as "SUBINSERT" or "SUBREMOVE" -> "SUBINS", "SUBREM", so that the UI works better.

Also fixed a couple of bugs regarding mutation event targets and subtree elements. Had to also re-implement how changing element text node text works. The most interesting takeaway here is that for some reason, if we try to change element's textnode's text with elem.childNodes[0].value = text, MutationObserver will not catch that - I guess it doesn't count as any kind of "change in the DOM tree".

// Can't use elem.textContent / innerText / innerHTML...
// might accidentally overwrite element's subtree

// Can't do it like this, because MutationObserver can't see this (why??)
// elem.childNodes[0].value = text;

// This is the way
elem.replaceChild(document.createTextNode(text), elem.childNodes[0]);

At this point I've noticed that a "cleaning" process is required when replaying events:

// Used to clean "dirtied" elements before replaying.
// Consider the event flow:
// 1. Create element X
// 2. Change X CSS or subinsert to X
// 3. Remove X
// 4. Replay
// Without cleanup, during replay at step 1, the created element
// will already have "later" CSS changes and subinserts applied to it ahead of time
// because of ""the way we do things"".
// This fixes that issue
__clean() 
{
  // OLD CLEANUP METHOD
  /*while (this.element.lastElementChild) {
    this.element.lastElementChild.style = "";
    this.element.removeChild(this.element.lastElementChild);
  }
  this.element.style = "";
  this.element.innerText = this.element.id;*/
  
  // NEW CLEANUP METHOD (don't have to care what changed)
  // Reset event's element
  this.element = this.originalElement.cloneNode(true);   
  // Must remember to set the original id back
  // Or else it will be the clone's id
  this.element.id = this.originalId;
}

As explained in the code snippet comments, there are certain "event flows", which could lead to wrong replay results, such as some events/changes being applied to elements ahead of time so to speak.

To fix this, each event performs an internal "cleaning process" before actually replaying itself. This might not be needed, were the implementation a bit different, but for now it has to be done.

When it comes to the question "What exactly needs to be cleaned?", I am honestly not sure. As far as I know, we seem to have to clean everything that any event could possibly modify on an element.

OLD WAY

Currently that means:

  • Clearing element DOM subtree of children
  • Resetting element inline styles
  • Resetting element's innerText to what it originally is (it's id)

NEW WAY

I figured we can get around caring what needs to be reset in the cleaning process by just creating a clone of each even't original related element and then replacing the event's element with the original unchanged clone before replaying:

class MutationEvent
{
  constructor(recorderInstance, event, element, data)
  {
    this.originalId = this.element.id;
    this.originalElement = this.element.cloneNode(true);
    // Important to change the id to "hide" the clone
    this.originalElement.id = getId("o-");
    ...
  }
}

Anyways, here's the code:

const $recordable = document.querySelector('#recordable');
const $eventlist = document.querySelector('#event-list');
const $elementlist = document.querySelector('#element-list');

function getId(prefix = "d-") {
  return prefix + Math.random().toString(16).slice(2, 8);
}

// MutationEvent class
class MutationEvent
{
  constructor(recorderInstance, event, element, data)
  {
    this.recorderInstance = recorderInstance;
    this.event = event;
    this.element = element.cloneNode(true);
    
    // For cleanup
    this.originalId = this.element.id;
    this.originalElement = this.element.cloneNode(true);
    // Important to change the id to "hide" the clone
    this.originalElement.id = getId("o-");
    
    this.data = data || { };
    this.static_replay_id = getId("r-");
  }
  
  getStaticId() 
  {
    return this.static_replay_id;
  }
  
  // Used to clean "dirtied" elements before replaying.
  // Consider the event flow:
  // 1. Create element X
  // 2. Change X CSS or subinsert to X
  // 3. Remove X
  // 4. Replay
  // Without cleanup, during replay at step 1, the created element
  // will already have "later" CSS changes and subinserts applied to it ahead of time
  // because of ""the way we do things"".
  // This fixes that issue
  __clean() 
  {
    // OLD CLEANUP METHOD
    /*while (this.element.lastElementChild) {
      this.element.lastElementChild.style = "";
      this.element.removeChild(this.element.lastElementChild);
    }
    this.element.style = "";
    this.element.innerText = this.element.id;*/
    
    // NEW CLEANUP METHOD (don't have to care what changed)
    // Reset event's element
    this.element = this.originalElement.cloneNode(true);   
    // Must remember to set the original id back
    // Or else it will be the clone's id
    this.element.id = this.originalId;
  }
  
  replay($targetid, useActor = false)
  {
    let elem = null;
    let success = false;
    this.__clean();
    
    switch(this.event)
    {
      case 'TEXT':
        elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
        if(elem) {
          elem.replaceChild(document.createTextNode(this.data.text), elem.childNodes[0]);
          success = true;
        }
        break;
      case 'INSERT':
        elem = document.querySelector(`#${$targetid}`);
        if(elem) {
          let actorElement = null;
          
          if(useActor) {
            actorElement = this.element.cloneNode(true);
            actorElement.id = getId();
          }
          else {
            actorElement = this.element;
          }
          elem.append(actorElement);
          success = true;
        }
        break;
      case 'SUBINS':
        elem = document.querySelector(
        `#${$targetid} div#${
          this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)
        }`);

        if(elem) {
          let actorElement = null;
          
          if(useActor) {
            actorElement = this.element.cloneNode(true);
            actorElement.id = getId();
          }
          else {
            actorElement = this.element;
          }
          elem.append(actorElement);
          success = true;
        }
        break;
      case 'CSS':
        elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
        if(elem && typeof this.data.css_rules !== 'undefined') {
          this.data.css_rules.forEach((r) => {
            elem.style[r.rule] = r.value;
          });
          success = true;
        }
        break;
      case 'REMOVE':
        elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
        if(elem) {
          elem.remove();
          success = true;
        }
        break;
      case 'SUBREM':
        elem = document.querySelector(
        `#${$targetid} div#${
          this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)
        }`);
        
        if(elem) {
          elem.remove();
          success = true;
        }
        break;
      default:
        break;
    }
    
    return success;
  }
  
  toString()
  {
    return `${this.event}: ${this.element.id}`;
  }
}

// Dom recorder / MutationObserver stuff
class DOMRecorder
{
  constructor(targetNode, config)
  {
    this.recording = false;
    this.replaying = false;
    this.targetNode = targetNode;
    this.config = config;
    this.observer = null;
    this.elementIds = [];
    this.events = [];
  }
  
  start()
  {
    if(this.observer === null)
      this.observer = new MutationObserver(this.recordEvent.bind(this));
      
    this.observer.observe(this.targetNode, this.config);
    this.recording = true;
  }
  
  getStaticIdForMutationTarget(targetid)
  {
    return this.events.find((evt) => evt.element.id === targetid).static_replay_id;
  }
  
  getEventByTargetId(targetid)
  {
    return this.events.find((evt) => evt.element.id === targetid);
  }
  
  getEventTargetByStaticReplayId(replay_id)
  {
    let targetid = null;
    
    for(let i = 0; i < this.events.length; i++) {
      let evt = this.events[i];
      
      if(!"static_replay_id" in evt.data)
        continue;
      
      if(evt.static_replay_id === replay_id) {
        targetid = evt.element.id;
        break;
      }
    }
    
    return targetid;
  }
  
  stop()
  {
    this.observer.disconnect();
    this.recording = false;
  }
  
  update()
  {
    this.updateElementList();
    this.updateEventList();
  }
  
  updateElementList()
  {
    let options = this.elementIds.map((id) => {
      let option = document.createElement("option");
      option.value = id;
      option.innerText = id;
      return option;
    });
    $elementlist.replaceChildren(...options);
  }
  
  updateEventList()
  {
    let options = this.events.map((e) => {
      let option = document.createElement("option");
       // This line breaks syntax-highlighting, if `` is used???
      option.value = e.event + ";" + e.element.id;
      
      option.innerText = e.toString();
      return option;
    });
    $eventlist.replaceChildren(...options);
  }
  
  recordEvent(mutationList, observer)
  {
    for(const mutation of mutationList) {
      // Element text changed event
      if(mutation.type === 'childList' &&
      mutation.addedNodes.length > 0 &&
      mutation.addedNodes[0].nodeName === '#text') {
        this.events.push(new MutationEvent(
          this,
          "TEXT",
          mutation.addedNodes[0].parentElement,
          { text: mutation.addedNodes[0].nodeValue }
        ));  
      }
      // Element added event
      else if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
        // Element added directly into the recorded element
        if(mutation.target.id === this.targetNode.id) {
          this.events.push(new MutationEvent(this, "INSERT", mutation.addedNodes[0]));  
          this.elementIds.push(mutation.addedNodes[0].id);
        }
        // Element added to a subtree of some element in the recorded element
        else {
          this.events.push(new MutationEvent(
            this,
            "SUBINS", 
            mutation.addedNodes[0],
            { subtree_target: this.getStaticIdForMutationTarget(mutation.target.id) }
          ));  
          this.elementIds.push(mutation.addedNodes[0].id);
        }
      }
      // Element removed event
      else if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
        // Element removed directly from the recorded element
        if(mutation.target.id === this.targetNode.id) {
          this.events.push(new MutationEvent(this, "REMOVE", mutation.removedNodes[0]));
        }
        // Element removed from a subtree of some element in the recorded element
        else {
          this.events.push(new MutationEvent(
            this, 
            "SUBREM", 
            mutation.removedNodes[0],
            { subtree_target: this.getStaticIdForMutationTarget(mutation.removedNodes[0].id) }
          ));
        }
        
        this.elementIds.splice(this.elementIds.indexOf(mutation.removedNodes[0].id), 1);
        
        // Clean up element list, if we remove a parent element which has child elements
        let childNodes = mutation.removedNodes[0].getElementsByTagName('*');
        Array.from(childNodes).forEach((n) => {
          this.elementIds.splice(this.elementIds.indexOf(n.id), 1);
        });
      }
      // Element inline CSS changed
      else if(mutation.type === 'attributes' && mutation.attributeName === "style") {
        // Perform some magic to find specifically only the changed
        // inline style names of the element, as well as their values
        let addedStyles = Object.keys(mutation.target.style).filter((k) => {
          return Number.isInteger(parseInt(k));
        }).map((styleKey) => {
          // Change rule names like "background-color" -> "backgroundColor"
          let rule = mutation.target.style[styleKey]
            .replace(/-(.)/g, (m,p) => p.toUpperCase());
          let value = mutation.target.style[rule];
          return { rule, value }
        });
        
        this.events.push(new MutationEvent(
          this, 
          "CSS", 
          mutation.target,
          { css_rules: addedStyles }
        ));      
      }     
    } 
    this.update();
  }
  
  replayEvent(event, id, $targetid)
  {
    let replayEvent = this.events
      .find((evt) => evt.event === event && evt.element.id === id);
    
    if(!replayEvent) {
      console.log(`Could not find event: "${event}", ID: "${id}"`);
      return;
    }
    
    replayEvent.replay($targetid, true);
  }
  
  replayEvents(speed, $targetid)
  {
    if(this.replaying)
      return;
    
    this.replaying = true;
    
    if(this.recording)
      this.stop();
    
    $recordable.innerHTML = '';
    
    let i = 0;
    
    let replayInterval = setInterval(() => {
      if(i < this.events.length) {
        this.events[i].replay($targetid);
        i++;
      }
      else {
        clearInterval(replayInterval);
        this.replaying = false;
        this.start();
      }
    }, speed);
  }
}

// Example controls events
function addElement() {
  let id = getId();
  let div = document.createElement("div");
  div.id = id;
  div.innerHTML = id;
  $recordable.append(div);
}

function addElementSubtree() {
  let selected = $elementlist.value || null;
  
  if(selected === null) {
    console.log('No element selected');
    return;
  }
  
  let id = getId();
  let div = document.createElement("div");
  div.id = id;
  div.innerHTML = id;
  
  let elem = document.querySelector(`#recordable div#${selected}`);
  
  if(elem)
    elem.append(div);
}

function changeCSS() {
  let selected = $elementlist.value || null;
  
  if(selected === null) {
    console.log('No element selected');
    return;
  }
  
  let userCSS = prompt("Input a single CSS rule like background-color: red");
  userCSS = userCSS.length === 0 ? "background-color: red" : userCSS;
  let [cssRuleName, cssRule] = userCSS.split(':').map((s) => s.trim());
  cssRuleName = cssRuleName.replace(/-(.)/g, (m,p) => p.toUpperCase());
  
  
  let elem = document.querySelector(`#recordable div#${selected}`);
  
  if(elem)
    elem.style[cssRuleName] = cssRule;
}

function removeElement() {
  let selected = $elementlist.value || null;
  
  if(selected === null) {
    console.log('No element selected');
    return;
  }
  
  let elem = document.querySelector(`#recordable div#${selected}`);
  
  if(elem)
    elem.remove();
}

function changeText()
{
  let selected = $elementlist.value || null;
  
  if(selected === null) {
    console.log('No element selected');
    return;
  }
  
  let elem = document.querySelector(`#recordable div#${selected}`);
  
  if(elem) {
    let text = prompt("Insert text for element");
    
    // Can't use elem.textContent / innerText / innerHTML...
    // might accidentally overwrite element's subtree
    
    // Can't do it like this, because MutationObserver can't see this (why??)
    // elem.childNodes[0].value = text;
    
    // This is the way
    elem.replaceChild(document.createTextNode(text), elem.childNodes[0]);
  }
}

function replayOne()
{
  let selected = $eventlist.value || null;
  
  if(selected === null) {
    console.log('No element selected');
    return;
  }
  
  let [event, id] = selected.split(';');
  
  recorder.replayEvent(event, id, $recordable.id);
  
}

function replayAll()
{
  let speed = prompt("Input speed in ms for the replay") || 350;
  recorder.replayEvents(speed, $recordable.id);
}

const recorder = new DOMRecorder(
  $recordable,
  { attributes: true, childList: true, subtree: true }
);

recorder.start();
*
{
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body
{
  width: 100%;
  height: 100%;
}

#recordable
{
  width: 100%;
  height: calc(100% - 35px);
  background-color: #7f8fa6;
  padding: 5px;
  
  display: -ms-flexbox;
  display: -webkit-flex;
  display: flex;
  -webkit-flex-direction: row;
  -ms-flex-direction: row;
  flex-direction: row;
  -webkit-flex-wrap: wrap;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
  -webkit-justify-content: flex-start;
  -ms-flex-pack: start;
  justify-content: flex-start;
  -webkit-align-content: flex-start;
  -ms-flex-line-pack: start;
  align-content: flex-start;
  -webkit-align-items: flex-start;
  -ms-flex-align: start;
  align-items: flex-start;
}

#controls
{
  width: 100%;
  height: 80px;
  background-color: #273c75;
  
  display: -ms-flexbox;
  display: -webkit-flex;
  display: flex;
  -webkit-flex-direction: row;
  -ms-flex-direction: row;
  flex-direction: row;
  -webkit-flex-wrap: nowrap;
  -ms-flex-wrap: nowrap;
  flex-wrap: nowrap;
  -webkit-justify-content: center;
  -ms-flex-pack: center;
  justify-content: center;
  -webkit-align-content: stretch;
  -ms-flex-line-pack: stretch;
  align-content: stretch;
  -webkit-align-items: center;
  -ms-flex-align: center;
  align-items: center;
}

button, select
{
  padding: 2px;
  margin: 0 3px 0 3px;
}

#recordable > div
{
  width: 120px;
  min-height: 35px;
  
  padding: 4px;
  line-height: calc(35px - 4px);
  background-color: #2f3640;
  color: #dcdde1;
  font-family: "courier-new", Arial;
  font-size: 10pt;
  margin: 0px 5px 5px 5px;
  text-align: center;
}

#recordable > div > *
{
  padding: 0px;
  line-height: calc(35px - 4px);
  background-color: #192a56;
  color: #dcdde1;
  font-family: "courier-new", Arial;
  font-size: 10pt;
  margin: 5px 5px 5px 5px !important;
  text-align: center;
}
<div id = "controls">
  <button id = "add-control" onClick = "addElement()">Add</button>
  <button id = "add-subtree-control" onClick = "addElementSubtree()">Add sub</button>
  <button id = "add-control" onClick = "removeElement()">Remove</button>  
  <button id = "css-control" onClick = "changeCSS()">CSS</button>
  <button id = "edit-control" onClick = "changeText()">Edit</button>
  <select id = "element-list">
  </select>
  <select id = "event-list">
  </select>
  <button id = "replay-control" onClick = "replayOne()">Replay</button>
  <button id = "replay-all-control" onClick = "replayAll()">Replay all</button>
</div>

<div id = "recordable">

</div>
like image 126
Swiffy Avatar answered Feb 01 '26 23:02

Swiffy



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!