Skip to content

MutationObserver stops firing in Bun.js #1878

@brainkim

Description

@brainkim

Describe the bug

import { Window } from 'happy-dom';

console.log('Testing MutationObserver behavior in detail...');

const window = new Window();
const document = window.document;

let mutationCount = 0;
const observer = new window.MutationObserver((mutations) => {
  mutationCount++;
  console.log(`\n🔄 Mutation batch #${mutationCount}: ${mutations.length} mutations`);
  mutations.forEach((mutation, i) => {
    console.log(`  ${i+1}. Type: ${mutation.type}`);
    console.log(`     Target: ${mutation.target.nodeName}`);
    console.log(`     AttributeName: ${mutation.attributeName}`);
    if (mutation.type === 'characterData') {
      console.log(`     OldValue: "${mutation.oldValue}"`);
      console.log(`     NewValue: "${mutation.target.textContent}"`);
    }
  });
});

observer.observe(document.documentElement, {
  childList: true,
  subtree: true,
  attributes: true,
  characterData: true,
  attributeOldValue: true,
  characterDataOldValue: true
});

// Create element
console.log('\n--- Creating element ---');
const div = document.createElement('div');
div.textContent = 'Initial';
document.body.appendChild(div);

// Wait for async mutations
await new Promise(resolve => setTimeout(resolve, 10));
console.log(`Mutations so far: ${mutationCount}`);

// Test textContent change
console.log('\n--- Changing textContent ---');
div.textContent = 'Changed text';

await new Promise(resolve => setTimeout(resolve, 10));
console.log(`Mutations so far: ${mutationCount}`);

// Test direct text node modification
console.log('\n--- Modifying text node directly ---');
if (div.firstChild && div.firstChild.nodeType === 3) { // Text node
  div.firstChild.textContent = 'Direct change';
}

await new Promise(resolve => setTimeout(resolve, 10));
console.log(`Mutations so far: ${mutationCount}`);

// Test setAttribute vs style.setProperty
console.log('\n--- Setting attribute directly ---');
div.setAttribute('style', 'color: red');

await new Promise(resolve => setTimeout(resolve, 10));
console.log(`Mutations so far: ${mutationCount}`);

console.log('\n--- Using style.setProperty ---');
div.style.setProperty('background-color', 'blue');

await new Promise(resolve => setTimeout(resolve, 10));
console.log(`Mutations so far: ${mutationCount}`);

// Test className changes
console.log('\n--- Changing className ---');
div.className = 'test-class';

await new Promise(resolve => setTimeout(resolve, 10));
console.log(`Mutations so far: ${mutationCount}`);

console.log('\nFinal check after all changes...');
observer.disconnect();

To Reproduce
Steps to reproduce the behavior:
Run the script above in bun and node: I see the following in bun:

Testing MutationObserver behavior in detail...

--- Creating element ---

🔄 Mutation batch #1: 1 mutations
  1. Type: childList
     Target: BODY
     AttributeName: null
Mutations so far: 1

--- Changing textContent ---
Mutations so far: 1

--- Modifying text node directly ---
Mutations so far: 1

--- Setting attribute directly ---
Mutations so far: 1

--- Using style.setProperty ---
Mutations so far: 1

--- Changing className ---
Mutations so far: 1

Final check after all changes...

I see the following in node:

Testing MutationObserver behavior in detail...

--- Creating element ---

🔄 Mutation batch #1: 1 mutations
  1. Type: childList
     Target: BODY
     AttributeName: null
Mutations so far: 1

--- Changing textContent ---

🔄 Mutation batch #2: 2 mutations
  1. Type: childList
     Target: DIV
     AttributeName: null
  2. Type: childList
     Target: DIV
     AttributeName: null
Mutations so far: 2

--- Modifying text node directly ---

🔄 Mutation batch #3: 1 mutations
  1. Type: characterData
     Target: #text
     AttributeName: null
     OldValue: "Changed text"
     NewValue: "Direct change"
Mutations so far: 3

--- Setting attribute directly ---

🔄 Mutation batch #4: 1 mutations
  1. Type: attributes
     Target: DIV
     AttributeName: style
Mutations so far: 4

--- Using style.setProperty ---

🔄 Mutation batch #5: 1 mutations
  1. Type: attributes
     Target: DIV
     AttributeName: style
Mutations so far: 5

--- Changing className ---

🔄 Mutation batch #6: 1 mutations
  1. Type: attributes
     Target: DIV
     AttributeName: class
Mutations so far: 6

Final check after all changes...

Expected behavior
I expect changes to textContent, setAttribute, style.setProperty to cause the MutationObserver to fire in both runtimes. I’ve run the script several times on Bun and seen a situation where textContent causes MutationObserver to fire, so I suspect there’s some deeply f’ed race condition going on. It might have to do with internal garbage collection stuff because your MutationObserver uses WeakRefs.

Device:

  • OS: MacOS
  • Browser Bun.js
  • Version "happy-dom": "^18.0.1",

Additional context
Add any other context about the problem here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions