How uBlock Origin Handles DOM Mutations for Cosmetic Filtering
uBlock Origin watches the live DOM using a MutationObserver that batches structural and attribute changes into animation-frame callbacks, routing them to a cosmetic-filtering engine which extracts class and ID hashes, matches them against pre-compiled selector maps, and injects temporary stylesheets with display:none!important rules.
Cosmetic filtering in uBlock Origin must react to dynamic content injection to hide ad elements that appear after the initial page load. The gorhill/uBlock repository implements a high-performance, two-layer architecture where a centralized DOM watcher collects mutations and delegates them to specialized filterers that decide which CSS selectors must be applied or re-applied.
MutationObserver Architecture and Batch Processing
The core observation logic resides in src/js/contentscript.js. The startMutationObserver function creates a MutationObserver instance named domLayoutObserver that watches the entire document subtree for structural changes.
// src/js/contentscript.js
const startMutationObserver = function() {
if ( domLayoutObserver !== undefined ) { return; }
// Create a MutationObserver that receives every DOM change
domLayoutObserver = new MutationObserver(observerHandler);
domLayoutObserver.observe(document, {
childList: true, // added / removed elements
subtree: true // walk the whole tree
});
// Batch the work on the next animation frame to avoid thrashing
safeObserverHandlerTimer = new vAPI.SafeAnimationFrame(safeObserverHandler);
vAPI.shutdown.add(cleanup);
};
The observerHandler stores the addedNodes and removedNodes arrays in temporary buffers (addedNodeLists, removedNodeLists). When any buffer is non-empty, a safe animation-frame timer (safeObserverHandlerTimer) schedules the callback. In safeObserverHandler, the buffers collapse into plain arrays and each registered listener receives listener.onDOMChanged(addedNodes, removedNodes).
This batching strategy prevents layout thrashing when thousands of nodes mutate simultaneously during page hydration or infinite scroll events.
The Classic DOMFilterer Implementation
The classic cosmetic filterer (vAPI.DOMFilterer) registers itself as a listener via the vAPI.domWatcher API:
// src/js/contentscript.js
vAPI.domWatcher = { start, addListener, removeListener };
vAPI.domWatcher.addListener(vAPI.DOMFilterer);
The filterer implements onDOMChanged to handle newly injected content:
// src/js/contentscript.js – classic filterer
onDOMChanged: function(addedNodes) {
if ( addedNodes.length === 0 ) { return; }
for ( const node of addedNodes ) {
// If an <iframe> appears, hook its own DOM watcher
if ( node.localName === 'iframe' ) {
addIFrame(node);
}
// Descend into the subtree, looking for further <iframe>s
const iframes = node.getElementsByTagName('iframe');
if ( iframes.length !== 0 ) {
addIFrames(iframes);
}
}
// Run the procedural cosmetic filter on the new nodes
process();
}
When an iframe element enters the DOM, the filterer recursively attaches observers to the nested document to ensure cosmetic rules apply within sandboxed content.
MV3 Generic Selector Processing
The Manifest V3 build maintains the same high-level design but isolates the logic in platform/mv3/extension/js/scripting/css-generic.js. This implementation creates its own mutation observer focused on attribute changes:
// platform/mv3/extension/js/scripting/css-generic.js
let domMutationObserver = new MutationObserver(uBOL_processChanges);
domMutationObserver.observe(document, {
attributeFilter: [ 'class', 'id' ], // watch class & id changes
attributes: true,
childList: true,
subtree: true,
});
The uBOL_processChanges callback accumulates mutated nodes into a pendingNodes set and schedules processing via uBOL_processNodes. For each node, the engine:
- Extracts tokens using
uBOL_idFromNodeanduBOL_classesFromNode - Hashes tokens with a DJB2-variant (
hashFromStr) identical to the filter compiler - Looks up hashes in
genericSelectorMap, which holds pre-computed selector lists - Applies exceptions if the token matches
genericExceptionSieve, otherwise pushes selectors tostyleSheetSelectors
After processing all pending nodes, the engine injects a temporary stylesheet:
vAPI.userStylesheet.add(`${selectors.join(',')} { display:none !important; }`);
Observer Lifecycle and Resource Management
Both implementations implement automatic cleanup to prevent memory leaks in idle tabs. The classic version exposes stopMutationObserver:
// src/js/contentscript.js
const stopMutationObserver = function() {
if ( domLayoutObserver === undefined ) { return; }
cleanup();
vAPI.shutdown.remove(cleanup);
};
The MV3 version uses a 20-second inactivity timeout (needDomChangeObserver) to disconnect the observer when the DOM stabilizes:
// platform/mv3/extension/js/scripting/css-generic.js
const needDomChangeObserver = () => {
if ( (Date.now() - lastDomChange) > 20000 ) {
return stopAll('no more DOM changes');
}
domChangeTimer = self.setTimeout(needDomChangeObserver, 20000);
};
The stopAll function clears timers, disconnects the observer, and purges the selector map to free resources.
Summary
- Batching architecture:
MutationObserverinsrc/js/contentscript.jsbuffers mutations and dispatches them on animation frames to minimize reflow costs. - Dual implementations: The classic
vAPI.DOMFiltererhandles procedural filters and iframes, while the MV3css-generic.jsuses hash-based matching for generic selectors. - Attribute monitoring: MV3 specifically watches
classandidattributes to catch dynamic CSS class changes that often indicate ad container injection. - Resource safety: Automatic observer shutdown after 20 seconds of inactivity prevents background tab resource exhaustion.
Frequently Asked Questions
What triggers a cosmetic filter recalculation in uBlock Origin?
DOM mutations—specifically childList changes for node insertion/removal and attribute changes for class and id—trigger recalculation. The observer batches these mutations into a single animation-frame callback, then the filterer extracts hashes from new nodes and checks them against the compiled selector map to determine which hide rules to inject.
How does uBlock Origin handle dynamically added iframes?
The onDOMChanged method in vAPI.DOMFilterer detects iframe elements in added nodes and calls addIFrame to recursively attach DOM watchers to the nested document. It also descends into added subtrees using getElementsByTagName('iframe') to catch nested frames that might not trigger direct mutation records.
What is the difference between the classic and MV3 mutation handling approaches?
The classic implementation (src/js/contentscript.js) uses procedural cosmetic filters and handles complex selectors through vAPI.DOMFilterer, while the MV3 build (platform/mv3/extension/js/scripting/css-generic.js) relies on pre-compiled hash maps of generic selectors. The MV3 version optimizes for restricted extension environments by hashing class/id tokens with a DJB2 algorithm and matching against genericSelectorMap rather than evaluating CSS selectors in real-time.
Why does uBlock Origin stop observing DOM mutations after a period of inactivity?
To conserve memory and CPU in background tabs, the MV3 implementation starts a 20-second timer (needDomChangeObserver) that disconnects the MutationObserver if no mutations occur. This stopAll cleanup clears the observer, timers, and selector caches, ensuring the extension does not consume resources on static pages.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →