[DONE] MutationObserver Microtask-Batching — bereits in Happy DOM 20.10.5 implementiert #30

Closed
opened 2026-06-17 16:34:29 +00:00 by Artur · 0 comments
Owner

Problem

React batcht setState-Aufrufe in einer Microtask-Queue. Wenn der State committed wird, mutiert React das DOM synchron (appendChild, setAttribute, etc.). Danach erwartet React, dass MutationObserver-Callbacks einmal pro Batch feuern — nicht 50x einzeln.

Aktuelles Verhalten in Happy DOM:

  • element.appendChild(child) → ruft sofort observer.callback([mutation]) auf
  • React macht 50 DOM-Mutationen → 50 separate MO-Callbacks → React Scheduler crasht

Erwartetes Verhalten (realer Browser):

  • 50 appendChild-Aufrufe erzeugen 50 MutationRecords
  • Erst im nächsten Microtask werden ALLE Records gesammelt an den Callback übergeben
  • Der Callback feuert einmal mit [record1, record2, ..., record50]

Option A: Happy DOM fork / monkey-patch (Empfohlen)

Happy DOMs MutationObserver-Implementierung liegt in MutationObserver._sendRecord() und wird von jedem DOM-Setter aufgerufen.

// Aktuell (in happy-dom):
public _sendRecord(mutation: MutationRecord): void {
  this._records.push(mutation);
  this.callback(mutation, this); // ← synchron!
}

// Fix:
public _sendRecord(mutation: MutationRecord): void {
  this._records.push(mutation);
  // Nicht direkt feuern — im Microtask sammeln
  if (!this._pending) {
    this._pending = true;
    queueMicrotask(() => {
      this._pending = false;
      const records = this._records.splice(0);
      if (records.length > 0) {
        this.callback(records, this);
      }
    });
  }
}

Vorteile: Minimaler Eingriff, nutzt Bun natively Event-Loop, kein externer Scheduler nötig.
Nachteile: Keine.

Option B: Eigener Scheduler

Eigenen MicrotaskQueue-Manager bauen, der alle asynchronen Callbacks sammelt und priorisiert.

Nachteile: Overengineering. Bun/JSC Event-Loop macht das bereits korrekt — wir müssen nur aufhören, synchron dazwischenzufunken.

Akzeptanzkriterien

  • MutationObserver feuert Callback einmal nach synchronem DOM-Batch
  • mutation.oldValue und attributeName bleiben korrekt
  • disconnect() bricht pending Microtask ab
  • takeRecords() liefert auch pending Records
  • Alle bestehenden MO-Tests bleiben grün
  • Neuer Test: 50x appendChild in Loop → 1 Callback mit 50 Records
  • Bestehende Non-React-Seiten (Hacker News, httpbin) bleiben funktionsfähig

Betroffene Dateien

Datei Änderung
node_modules/happy-dom/lib/MutationObserver.js _sendRecord() → queueMicrotask + pending-Flag
src/fakes/patch-mutation-observer.ts Neu: Wrapper der Happy-DOM-Klasse (optional, falls Original nicht gepatcht werden soll)
tests/unit/mutation-observer-batch.test.ts Neu: 15+ Tests für Batching-Verhalten

Tests

describe("MutationObserver Batching", () => {
  it("buffers records during synchronous mutations");
  it("fires callback once per microtask with all records");
  it("disconnect() cancels pending microtask");
  it("takeRecords() returns buffered records");
  it("takeRecords() after disconnect() returns empty");
  it("multiple observers are independent");
  it("childList mutations are collected correctly");
  it("attributes mutations are collected correctly");
  it("characterData mutations are collected correctly");
  it("subtree mutations are collected correctly");
  it("observer with options filters correctly after batch");
  it("oldValue is captured before mutation");
  it("50 appendChild calls → 1 callback with 50 records");
  it("batching does not affect synchronous event dispatch");
  it("microtask timing matches real browser (setTimeout after)");
})

Cross-Referenzen

  • #31 React Forms braucht korrektes MO-Batching für form-spezifische Updates
  • #32 Event-Reihenfolge profitiert davon (focusin/focusout feuern in korrektem Microtask-Kontext)

Notizen

HAPPY-DOM-BATCHING.md — Referenz zu Happy DOMs internem MO-Mechanismus.

## Problem React batcht `setState`-Aufrufe in einer Microtask-Queue. Wenn der State committed wird, mutiert React das DOM synchron (appendChild, setAttribute, etc.). Danach **erwartet** React, dass `MutationObserver`-Callbacks **einmal pro Batch** feuern — nicht 50x einzeln. Aktuelles Verhalten in Happy DOM: - `element.appendChild(child)` → ruft sofort `observer.callback([mutation])` auf - React macht 50 DOM-Mutationen → 50 separate MO-Callbacks → React Scheduler crasht Erwartetes Verhalten (realer Browser): - 50 `appendChild`-Aufrufe erzeugen 50 MutationRecords - Erst im **nächsten Microtask** werden ALLE Records gesammelt an den Callback übergeben - Der Callback feuert **einmal** mit `[record1, record2, ..., record50]` ## Option A: Happy DOM fork / monkey-patch (Empfohlen) Happy DOMs `MutationObserver`-Implementierung liegt in `MutationObserver._sendRecord()` und wird von jedem DOM-Setter aufgerufen. ```typescript // Aktuell (in happy-dom): public _sendRecord(mutation: MutationRecord): void { this._records.push(mutation); this.callback(mutation, this); // ← synchron! } // Fix: public _sendRecord(mutation: MutationRecord): void { this._records.push(mutation); // Nicht direkt feuern — im Microtask sammeln if (!this._pending) { this._pending = true; queueMicrotask(() => { this._pending = false; const records = this._records.splice(0); if (records.length > 0) { this.callback(records, this); } }); } } ``` **Vorteile:** Minimaler Eingriff, nutzt Bun natively Event-Loop, kein externer Scheduler nötig. **Nachteile:** Keine. ## Option B: Eigener Scheduler Eigenen `MicrotaskQueue`-Manager bauen, der alle asynchronen Callbacks sammelt und priorisiert. **Nachteile:** Overengineering. Bun/JSC Event-Loop macht das bereits korrekt — wir müssen nur aufhören, synchron dazwischenzufunken. ## Akzeptanzkriterien - [ ] `MutationObserver` feuert Callback **einmal** nach synchronem DOM-Batch - [ ] `mutation.oldValue` und `attributeName` bleiben korrekt - [ ] `disconnect()` bricht pending Microtask ab - [ ] `takeRecords()` liefert auch pending Records - [ ] Alle bestehenden MO-Tests bleiben grün - [ ] Neuer Test: 50x appendChild in Loop → 1 Callback mit 50 Records - [ ] Bestehende Non-React-Seiten (Hacker News, httpbin) bleiben funktionsfähig ## Betroffene Dateien | Datei | Änderung | |-------|----------| | `node_modules/happy-dom/lib/MutationObserver.js` | `_sendRecord()` → queueMicrotask + pending-Flag | | `src/fakes/patch-mutation-observer.ts` | **Neu:** Wrapper der Happy-DOM-Klasse (optional, falls Original nicht gepatcht werden soll) | | `tests/unit/mutation-observer-batch.test.ts` | **Neu:** 15+ Tests für Batching-Verhalten | ## Tests ```typescript describe("MutationObserver Batching", () => { it("buffers records during synchronous mutations"); it("fires callback once per microtask with all records"); it("disconnect() cancels pending microtask"); it("takeRecords() returns buffered records"); it("takeRecords() after disconnect() returns empty"); it("multiple observers are independent"); it("childList mutations are collected correctly"); it("attributes mutations are collected correctly"); it("characterData mutations are collected correctly"); it("subtree mutations are collected correctly"); it("observer with options filters correctly after batch"); it("oldValue is captured before mutation"); it("50 appendChild calls → 1 callback with 50 records"); it("batching does not affect synchronous event dispatch"); it("microtask timing matches real browser (setTimeout after)"); }) ``` ## Cross-Referenzen - #31 React Forms braucht korrektes MO-Batching für form-spezifische Updates - #32 Event-Reihenfolge profitiert davon (focusin/focusout feuern in korrektem Microtask-Kontext) ## Notizen [HAPPY-DOM-BATCHING.md](./docs/HAPPY-DOM-BATCHING.md) — Referenz zu Happy DOMs internem MO-Mechanismus.
Artur changed title from MutationObserver Microtask-Batching — React setState bricht zusammen to [DONE] MutationObserver Microtask-Batching — bereits in Happy DOM 20.10.5 implementiert 2026-06-17 16:37:33 +00:00
Artur closed this issue 2026-06-17 16:37:33 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
glow-all/true-headless-browser#30
No description provided.