I’m trying to have a MutationObserver watch for changes to a page title and immediately reset the title back to the original text. This partially works, but the first time the page title is changed, my callback keeps firing and the page grinds to a halt. I’ve tried disconnecting the observer in my callback, and that stops it from looping, but I actually need it to keep observing, so it isn’t a solution.

What am I doing wrong?

function resetTitle(title) {
    document.title = title[0].oldValue;
    console.info("Reset the page title.");
}

let observer = new MutationObserver(resetTitle);

observer.observe(document.querySelector('title'), {
  childList: true,
  subtree: true,
  characterDataOldValue: true
});

1

First, when the document.title is set, the string replace all algorithm is used, which will replace all the previous content of the <title> element with a new TextNode.
Doing so, there is no CharacterData mutation occurring, only a childList one, which will not fill the previousValue field.

document.title = "original title";
// StackSnippets don't set a title, so we need to wait after we set the initial
// title before starting our observer
setTimeout(() => {
  function resetTitle(title) {
      console.log("oldValue:", title[0].oldValue);
      console.log("type:", title[0].type);
  }

  let observer = new MutationObserver(resetTitle);

  observer.observe(document.querySelector('title'), {
    childList: true,
    subtree: true,
    characterDataOldValue: true
  });
  document.title = Math.random();
}, 100)

You could have used that if the title was set by modifying the existing TextNode directly:

document.title = "original title";
// StackSnippets don't set a title, so we need to wait after we set the initial
// title before starting our observer
setTimeout(() => {
  function resetTitle([record]) {
    console.log("oldValue:", record.oldValue);
  }

  let observer = new MutationObserver(resetTitle);
  observer.observe(document.querySelector('title'), {
    subtree: true,
    characterDataOldValue: true,
  });

  document.querySelector('title').firstChild.data = Math.random();
}, 100);

So what you need is actually to handle both the possible CharacterData mutation, and the childList one, by looking at the removedNodes[0].data to get the old value.
But if you’re going to modify the title again in your handler, you will trigger once more the observer’s callback, with this time, the new title being set as the old one.
So instead, the best is to store the original value that you want to keep from outside of the observer’s callback, and to check in the callback if the title needs an update or not:

document.title = "original title";

setTimeout(() => {
  // Store the original title when you start the observer
  const originalTitle = document.title;
  function resetTitle([record]) {
    // Only if needed, even setting to the same value would trigger a childList change.
    if (document.title !== originalTitle) {
      document.title = originalTitle;
      console.log("resetting title");
    }
  }
  let observer = new MutationObserver(resetTitle);
  observer.observe(document.querySelector('title'), {
    childList: true,
    subtree: true,
    characterDataOldValue: true,
  });
  document.querySelector('button.title').onclick = e =>
    document.title = Math.random();
  document.querySelector('button.data').onclick = e =>
    document.querySelector('title').firstChild.data = Math.random();
}, 100);
<button class=title>change through `document.title`</button>
<button class=data>change through `TextNode.data`</button>