uBlock/src/js/contentscript-extra.js
Raymond Hill 9c3205b37c
Inject procedural cosmetic filterer's code only when needed
The procedural cosmetic filtering code has been split from
the content script code injected unconditionally and will
from now on be injected only when it is needed, i.e. when
there are procedural cosmetic filters to enforce.

The motivation for this is:
https://www.debugbear.com/blog/2020-chrome-extension-performance-report#what-can-extension-developers-do-to-keep-their-extensions-fast

Though uBO's content script injected unconditionally in all
pages/frames is relatively small, I still wanted to further
reduce the amount of content script code injected
unconditionally: The procedural cosmetic filtering code
represents roughly 14KB of code the browser won't have to
parse/execute unconditionally unless there exists procedural
cosmetic filters to enforce for a page or frame.

At the time the above article was published, the total
size of unconditional content scripts injected by uBO was
~101 KB, while after this commit, the total size will be
~57 KB (keeping in mind uBO does not minify and does not
remove comments from its JavaScript code).

Additionally, some refactoring on how user stylesheets are
injected so as to ensure that `:style`-based procedural
filters which are essentially declarative are injected
earlier along with plain, non-procedural cosmetic filters.
2021-02-17 09:12:00 -05:00

469 lines
14 KiB
JavaScript

/*******************************************************************************
uBlock Origin - a browser extension to block requests.
Copyright (C) 2014-present Raymond Hill
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see {http://www.gnu.org/licenses/}.
Home: https://github.com/gorhill/uBlock
*/
'use strict';
if (
typeof vAPI === 'object' &&
typeof vAPI.DOMProceduralFilterer !== 'object'
) {
// >>>>>>>> start of local scope
/******************************************************************************/
// TODO: Experiment/evaluate loading procedural operator code using an
// on demand approach.
// 'P' stands for 'Procedural'
const PSelectorHasTextTask = class {
constructor(task) {
let arg0 = task[1], arg1;
if ( Array.isArray(task[1]) ) {
arg1 = arg0[1]; arg0 = arg0[0];
}
this.needle = new RegExp(arg0, arg1);
}
transpose(node, output) {
if ( this.needle.test(node.textContent) ) {
output.push(node);
}
}
};
const PSelectorIfTask = class {
constructor(task) {
this.pselector = new PSelector(task[1]);
}
transpose(node, output) {
if ( this.pselector.test(node) === this.target ) {
output.push(node);
}
}
};
PSelectorIfTask.prototype.target = true;
const PSelectorIfNotTask = class extends PSelectorIfTask {
};
PSelectorIfNotTask.prototype.target = false;
const PSelectorMatchesCSSTask = class {
constructor(task) {
this.name = task[1].name;
let arg0 = task[1].value, arg1;
if ( Array.isArray(arg0) ) {
arg1 = arg0[1]; arg0 = arg0[0];
}
this.value = new RegExp(arg0, arg1);
}
transpose(node, output) {
const style = window.getComputedStyle(node, this.pseudo);
if ( style !== null && this.value.test(style[this.name]) ) {
output.push(node);
}
}
};
PSelectorMatchesCSSTask.prototype.pseudo = null;
const PSelectorMatchesCSSAfterTask = class extends PSelectorMatchesCSSTask {
};
PSelectorMatchesCSSAfterTask.prototype.pseudo = ':after';
const PSelectorMatchesCSSBeforeTask = class extends PSelectorMatchesCSSTask {
};
PSelectorMatchesCSSBeforeTask.prototype.pseudo = ':before';
const PSelectorMinTextLengthTask = class {
constructor(task) {
this.min = task[1];
}
transpose(node, output) {
if ( node.textContent.length >= this.min ) {
output.push(node);
}
}
};
const PSelectorSpathTask = class {
constructor(task) {
this.spath = task[1];
this.nth = /^(?:\s*[+~]|:)/.test(this.spath);
if ( this.nth ) { return; }
if ( /^\s*>/.test(this.spath) ) {
this.spath = `:scope ${this.spath.trim()}`;
}
}
qsa(node) {
if ( this.nth === false ) {
return node.querySelectorAll(this.spath);
}
const parent = node.parentElement;
if ( parent === null ) { return; }
let pos = 1;
for (;;) {
node = node.previousElementSibling;
if ( node === null ) { break; }
pos += 1;
}
return parent.querySelectorAll(
`:scope > :nth-child(${pos})${this.spath}`
);
}
transpose(node, output) {
const nodes = this.qsa(node);
if ( nodes === undefined ) { return; }
for ( const node of nodes ) {
output.push(node);
}
}
};
const PSelectorUpwardTask = class {
constructor(task) {
const arg = task[1];
if ( typeof arg === 'number' ) {
this.i = arg;
} else {
this.s = arg;
}
}
transpose(node, output) {
if ( this.s !== '' ) {
const parent = node.parentElement;
if ( parent === null ) { return; }
node = parent.closest(this.s);
if ( node === null ) { return; }
} else {
let nth = this.i;
for (;;) {
node = node.parentElement;
if ( node === null ) { return; }
nth -= 1;
if ( nth === 0 ) { break; }
}
}
output.push(node);
}
};
PSelectorUpwardTask.prototype.i = 0;
PSelectorUpwardTask.prototype.s = '';
const PSelectorWatchAttrs = class {
constructor(task) {
this.observer = null;
this.observed = new WeakSet();
this.observerOptions = {
attributes: true,
subtree: true,
};
const attrs = task[1];
if ( Array.isArray(attrs) && attrs.length !== 0 ) {
this.observerOptions.attributeFilter = task[1];
}
}
// TODO: Is it worth trying to re-apply only the current selector?
handler() {
const filterer =
vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer;
if ( filterer instanceof Object ) {
filterer.onDOMChanged([ null ]);
}
}
transpose(node, output) {
output.push(node);
if ( this.observed.has(node) ) { return; }
if ( this.observer === null ) {
this.observer = new MutationObserver(this.handler);
}
this.observer.observe(node, this.observerOptions);
this.observed.add(node);
}
};
const PSelectorXpathTask = class {
constructor(task) {
this.xpe = document.createExpression(task[1], null);
this.xpr = null;
}
transpose(node, output) {
this.xpr = this.xpe.evaluate(
node,
XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
this.xpr
);
let j = this.xpr.snapshotLength;
while ( j-- ) {
const node = this.xpr.snapshotItem(j);
if ( node.nodeType === 1 ) {
output.push(node);
}
}
}
};
const PSelector = class {
constructor(o) {
if ( PSelector.prototype.operatorToTaskMap === undefined ) {
PSelector.prototype.operatorToTaskMap = new Map([
[ ':has', PSelectorIfTask ],
[ ':has-text', PSelectorHasTextTask ],
[ ':if', PSelectorIfTask ],
[ ':if-not', PSelectorIfNotTask ],
[ ':matches-css', PSelectorMatchesCSSTask ],
[ ':matches-css-after', PSelectorMatchesCSSAfterTask ],
[ ':matches-css-before', PSelectorMatchesCSSBeforeTask ],
[ ':min-text-length', PSelectorMinTextLengthTask ],
[ ':not', PSelectorIfNotTask ],
[ ':nth-ancestor', PSelectorUpwardTask ],
[ ':spath', PSelectorSpathTask ],
[ ':upward', PSelectorUpwardTask ],
[ ':watch-attr', PSelectorWatchAttrs ],
[ ':xpath', PSelectorXpathTask ],
]);
}
this.raw = o.raw;
this.selector = o.selector;
this.tasks = [];
const tasks = o.tasks;
if ( Array.isArray(tasks) === false ) { return; }
for ( const task of tasks ) {
this.tasks.push(
new (this.operatorToTaskMap.get(task[0]))(task)
);
}
}
prime(input) {
const root = input || document;
if ( this.selector === '' ) { return [ root ]; }
return Array.from(root.querySelectorAll(this.selector));
}
exec(input) {
let nodes = this.prime(input);
for ( const task of this.tasks ) {
if ( nodes.length === 0 ) { break; }
const transposed = [];
for ( const node of nodes ) {
task.transpose(node, transposed);
}
nodes = transposed;
}
return nodes;
}
test(input) {
const nodes = this.prime(input);
for ( const node of nodes ) {
let output = [ node ];
for ( const task of this.tasks ) {
const transposed = [];
for ( const node of output ) {
task.transpose(node, transposed);
}
output = transposed;
if ( output.length === 0 ) { break; }
}
if ( output.length !== 0 ) { return true; }
}
return false;
}
};
PSelector.prototype.operatorToTaskMap = undefined;
const PSelectorRoot = class extends PSelector {
constructor(o, styleToken) {
super(o);
this.budget = 200; // I arbitrary picked a 1/5 second
this.raw = o.raw;
this.cost = 0;
this.lastAllowanceTime = 0;
this.styleToken = styleToken;
}
};
PSelectorRoot.prototype.hit = false;
const ProceduralFilterer = class {
constructor(domFilterer) {
this.domFilterer = domFilterer;
this.domIsReady = false;
this.domIsWatched = false;
this.mustApplySelectors = false;
this.selectors = new Map();
this.masterToken = vAPI.randomToken();
this.styleTokenMap = new Map();
this.styledNodes = new Set();
if ( vAPI.domWatcher instanceof Object ) {
vAPI.domWatcher.addListener(this);
}
}
addProceduralSelectors(selectors) {
const addedSelectors = [];
let mustCommit = this.domIsWatched;
for ( const selector of selectors ) {
if ( this.selectors.has(selector.raw) ) { continue; }
let style, styleToken;
if ( selector.action === undefined ) {
style = vAPI.hideStyle;
} else if ( selector.action[0] === ':style' ) {
style = selector.action[1];
}
if ( style !== undefined ) {
styleToken = this.styleTokenFromStyle(style);
}
const pselector = new PSelectorRoot(selector, styleToken);
this.selectors.set(selector.raw, pselector);
addedSelectors.push(pselector);
mustCommit = true;
}
if ( mustCommit === false ) { return; }
this.mustApplySelectors = this.selectors.size !== 0;
this.domFilterer.commit();
if ( this.domFilterer.hasListeners() ) {
this.domFilterer.triggerListeners({
procedural: addedSelectors
});
}
}
commitNow() {
if ( this.selectors.size === 0 || this.domIsReady === false ) {
return;
}
this.mustApplySelectors = false;
//console.time('procedural selectors/dom layout changed');
// https://github.com/uBlockOrigin/uBlock-issues/issues/341
// Be ready to unhide nodes which no longer matches any of
// the procedural selectors.
const toUnstyle = this.styledNodes;
this.styledNodes = new Set();
let t0 = Date.now();
for ( const pselector of this.selectors.values() ) {
const allowance = Math.floor((t0 - pselector.lastAllowanceTime) / 2000);
if ( allowance >= 1 ) {
pselector.budget += allowance * 50;
if ( pselector.budget > 200 ) { pselector.budget = 200; }
pselector.lastAllowanceTime = t0;
}
if ( pselector.budget <= 0 ) { continue; }
const nodes = pselector.exec();
const t1 = Date.now();
pselector.budget += t0 - t1;
if ( pselector.budget < -500 ) {
console.info('uBO: disabling %s', pselector.raw);
pselector.budget = -0x7FFFFFFF;
}
t0 = t1;
if ( nodes.length === 0 ) { continue; }
pselector.hit = true;
this.styleNodes(nodes, pselector.styleToken);
}
this.unstyleNodes(toUnstyle);
//console.timeEnd('procedural selectors/dom layout changed');
}
styleTokenFromStyle(style) {
if ( style === undefined ) { return; }
let styleToken = this.styleTokenMap.get(style);
if ( styleToken !== undefined ) { return styleToken; }
styleToken = vAPI.randomToken();
this.styleTokenMap.set(style, styleToken);
this.domFilterer.addCSS(
`[${this.masterToken}][${styleToken}]\n{${style}}`,
{ silent: true, mustInject: true }
);
return styleToken;
}
styleNodes(nodes, styleToken) {
if ( styleToken === undefined ) {
for ( const node of nodes ) {
node.textContent = '';
node.remove();
}
return;
}
for ( const node of nodes ) {
node.setAttribute(this.masterToken, '');
node.setAttribute(styleToken, '');
this.styledNodes.add(node);
}
}
// TODO: Current assumption is one style per hit element. Could be an
// issue if an element has multiple styling and one styling is
// brough back. Possibly too rare to care about this for now.
unstyleNodes(nodes) {
for ( const node of nodes ) {
if ( this.styledNodes.has(node) ) { continue; }
node.removeAttribute(this.masterToken);
}
}
createProceduralFilter(o) {
return new PSelectorRoot(o);
}
onDOMCreated() {
this.domIsReady = true;
this.domFilterer.commit();
}
onDOMChanged(addedNodes, removedNodes) {
if ( this.selectors.size === 0 ) { return; }
this.mustApplySelectors =
this.mustApplySelectors ||
addedNodes.length !== 0 ||
removedNodes;
this.domFilterer.commit();
}
};
vAPI.DOMProceduralFilterer = ProceduralFilterer;
/******************************************************************************/
// >>>>>>>> end of local scope
}
/*******************************************************************************
DO NOT:
- Remove the following code
- Add code beyond the following code
Reason:
- https://github.com/gorhill/uBlock/pull/3721
- uBO never uses the return value from injected content scripts
**/
void 0;