[mv3] Load specific cosmetic filtering data on-demand only

The cosmetic filtering data embedded in the content scripts has been
extracted in corresponding JSON files. When a list is enabled, the
corresponding cosmetic filtering data from the JSON files, if any, is
persisted into the extension's local storage.

The cosmetic filtering-related content scripts will load the data
from these JSON files on-demand only and matching cosmetic filters
will be extracted, then the result is cached in the session storage,
ensuring there is no longer a need to perform lookup for the rest
of the browser session.

As a result this further reduces the time to First Contentful Paint.

Related issue:
https://github.com/uBlockOrigin/uBOL-home/issues/557
This commit is contained in:
Raymond Hill 2025-12-09 15:32:02 -05:00
parent e036156f3a
commit 0fb845d1ac
No known key found for this signature in database
GPG key ID: F5630CAE62A14316
9 changed files with 244 additions and 220 deletions

View file

@ -19,6 +19,8 @@
Home: https://github.com/gorhill/uBlock
*/
import * as scrmgr from './scripting-manager.js';
import {
MODE_BASIC,
MODE_OPTIMAL,
@ -63,6 +65,7 @@ import {
browser,
localRead, localRemove, localWrite,
runtime,
sessionAccessLevel,
webextFlavor,
} from './ext.js';
@ -99,14 +102,13 @@ import {
} from './debug.js';
import { dnr } from './ext-compat.js';
import { registerInjectables } from './scripting-manager.js';
import { toggleToolbarIcon } from './action.js';
/******************************************************************************/
const UBOL_ORIGIN = runtime.getURL('').replace(/\/$/, '').toLowerCase();
const canShowBlockedCount = typeof dnr.setExtensionActionOptions === 'function';
const { registerInjectables } = scrmgr;
let pendingPermissionRequest;
@ -223,7 +225,7 @@ function onMessage(request, sender, callback) {
switch ( request.what ) {
case 'insertCSS': {
case 'insertCSS':
if ( frameId === false ) { return false; }
// https://bugs.webkit.org/show_bug.cgi?id=262491
if ( frameId !== 0 && webextFlavor === 'safari' ) { return false; }
@ -235,10 +237,11 @@ function onMessage(request, sender, callback) {
ubolErr(`insertCSS/${reason}`);
});
return false;
}
case 'removeCSS': {
case 'removeCSS':
if ( frameId === false ) { return false; }
// https://bugs.webkit.org/show_bug.cgi?id=262491
if ( frameId !== 0 && webextFlavor === 'safari' ) { return false; }
browser.scripting.removeCSS({
css: request.css,
origin: 'USER',
@ -247,7 +250,6 @@ function onMessage(request, sender, callback) {
ubolErr(`removeCSS/${reason}`);
});
return false;
}
case 'toggleToolbarIcon': {
if ( tabId ) {
@ -667,6 +669,10 @@ async function startSession() {
// launch time whether content css/scripts are properly registered.
registerInjectables();
// Cosmetic filtering-related content scripts cache fitlering data in
// session storage.
sessionAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' });
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest
// Firefox API does not support `dnr.setExtensionActionOptions`
if ( canShowBlockedCount ) {
@ -706,6 +712,8 @@ async function start() {
if ( process.wakeupRun === false ) {
await startSession();
} else {
scrmgr.onWakeupRun();
}
toggleDeveloperMode(rulesetConfig.developerMode);

View file

@ -100,6 +100,24 @@ export async function sessionRemove(key) {
return browser.storage.session.remove(key);
}
export async function sessionKeys() {
if ( notAnObject(browser?.storage?.session) ) { return; }
if ( browser.storage.session.getKeys ) {
return browser.storage.session.getKeys();
}
const bin = await browser.storage.session.get(null);
if ( notAnObject(bin) ) { return; }
return Object.keys(bin);
}
export async function sessionAccessLevel(level) {
try {
browser.storage.session.setAccessLevel(level);
} catch {
}
}
/******************************************************************************/
export async function adminRead(key) {

View file

@ -21,7 +21,11 @@
import * as ut from './utils.js';
import { browser, localRemove } from './ext.js';
import {
browser,
localKeys, localRemove, localWrite,
sessionKeys, sessionRead, sessionRemove, sessionWrite,
} from './ext.js';
import { ubolErr, ubolLog } from './debug.js';
import { fetchJSON } from './fetch.js';
@ -90,6 +94,15 @@ const normalizeRegisteredContentScripts = registered => {
/******************************************************************************/
async function resetCSSCache() {
const keys = await sessionKeys();
return Promise.all(
keys.filter(a => a.startsWith('cache.css.')).map(a => sessionRemove(a))
);
}
/******************************************************************************/
function registerHighGeneric(context, genericDetails) {
const { before, filteringModeDetails, rulesetsDetails } = context;
@ -286,16 +299,24 @@ function registerGeneric(context, genericDetails) {
/******************************************************************************/
function registerProcedural(context) {
async function registerCosmetic(realm, context) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const js = [];
for ( const rulesetDetails of rulesetsDetails ) {
const count = rulesetDetails.css?.procedural || 0;
if ( count === 0 ) { continue; }
js.push(`/rulesets/scripting/procedural/${rulesetDetails.id}.js`);
{
const keys = await localKeys();
for ( const key of keys ) {
if ( key.startsWith(`css.${realm}.`) === false ) { continue; }
localRemove(key);
}
}
if ( js.length === 0 ) { return; }
const rulesetIds = [];
for ( const rulesetDetails of rulesetsDetails ) {
const count = rulesetDetails.css?.[realm] || 0;
if ( count === 0 ) { continue; }
rulesetIds.push(rulesetDetails.id);
}
if ( rulesetIds.length === 0 ) { return; }
const { none, basic, optimal, complete } = filteringModeDetails;
const matches = [
@ -304,10 +325,24 @@ function registerProcedural(context) {
];
if ( matches.length === 0 ) { return; }
{
const promises = [];
for ( const id of rulesetIds ) {
promises.push(
fetchJSON(`/rulesets/scripting/${realm}/${id}`).then(data => {
return localWrite(`css.${realm}.${id}`, data);
})
);
}
await Promise.all(promises);
}
normalizeMatches(matches);
const realmid = `css-${realm}`;
const js = rulesetIds.map(id => `/rulesets/scripting/${realm}/${id}.js`);
js.unshift('/js/scripting/css-api.js', '/js/scripting/isolated-api.js');
js.push('/js/scripting/css-procedural.js');
js.push(`/js/scripting/${realmid}.js`);
const excludeMatches = [];
if ( none.has('all-urls') === false && basic.has('all-urls') === false ) {
@ -320,11 +355,11 @@ function registerProcedural(context) {
}
}
const registered = before.get('css-procedural');
before.delete('css-procedural'); // Important!
const registered = before.get(realmid);
before.delete(realmid); // Important!
const directive = {
id: 'css-procedural',
id: realmid,
js,
matches,
allFrames: true,
@ -346,71 +381,7 @@ function registerProcedural(context) {
ut.strArrayEq(registered.matches, matches) === false ||
ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push('css-procedural');
context.toAdd.push(directive);
}
}
/******************************************************************************/
function registerSpecific(context) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const js = [];
for ( const rulesetDetails of rulesetsDetails ) {
const count = rulesetDetails.css?.specific || 0;
if ( count === 0 ) { continue; }
js.push(`/rulesets/scripting/specific/${rulesetDetails.id}.js`);
}
if ( js.length === 0 ) { return; }
const { none, basic, optimal, complete } = filteringModeDetails;
const matches = [
...ut.matchesFromHostnames(optimal),
...ut.matchesFromHostnames(complete),
];
if ( matches.length === 0 ) { return; }
normalizeMatches(matches);
js.unshift('/js/scripting/css-api.js', '/js/scripting/isolated-api.js');
js.push('/js/scripting/css-specific.js');
const excludeMatches = [];
if ( none.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(none));
}
if ( basic.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(basic));
}
const registered = before.get('css-specific');
before.delete('css-specific'); // Important!
const directive = {
id: 'css-specific',
js,
matches,
allFrames: true,
runAt: 'document_start',
};
if ( excludeMatches.length !== 0 ) {
directive.excludeMatches = excludeMatches;
}
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
return;
}
// update
if (
ut.strArrayEq(registered.js, js, false) === false ||
ut.strArrayEq(registered.matches, matches) === false ||
ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push('css-specific');
context.toRemove.push(realmid);
context.toAdd.push(directive);
}
}
@ -499,7 +470,7 @@ function registerScriptlet(context, scriptletDetails) {
// Issue: Safari appears to completely ignore excludeMatches
// https://github.com/radiolondra/ExcludeMatches-Test
async function registerInjectables() {
export async function registerInjectables() {
if ( browser.scripting === undefined ) { return false; }
if ( registerInjectables.barrier ) { return true; }
@ -533,9 +504,9 @@ async function registerInjectables() {
};
await Promise.all([
registerProcedural(context),
registerScriptlet(context, scriptletDetails),
registerSpecific(context),
registerCosmetic('specific', context),
registerCosmetic('procedural', context),
registerGeneric(context, genericDetails),
registerHighGeneric(context, genericDetails),
registerCustomFilters(context),
@ -564,6 +535,8 @@ async function registerInjectables() {
}
}
await resetCSSCache();
registerInjectables.barrier = false;
return true;
@ -571,6 +544,25 @@ async function registerInjectables() {
/******************************************************************************/
export {
registerInjectables
};
export async function onWakeupRun() {
const cleanupTime = await sessionRead('scripting.manager.cleanup.time') || 0;
const now = Date.now();
const since = now - cleanupTime;
if ( since < (15 * 60 * 1000) ) { return; } // 15 minutes
const MAX_CACHE_ENTRY_LOW = 256;
const MAX_CACHE_ENTRY_HIGH = MAX_CACHE_ENTRY_LOW +
Math.min(Math.round(MAX_CACHE_ENTRY_LOW + MAX_CACHE_ENTRY_LOW / 8), 1);
const keys = await sessionKeys() || [];
const cacheKeys = keys.filter(a => a.startsWith('cache.css.'));
if ( cacheKeys.length < MAX_CACHE_ENTRY_HIGH ) { return; }
const entries = await Promise.all(cacheKeys.map(async a => {
const entry = await sessionRead(a) || {};
entry.key = a;
return entry;
}));
entries.sort((a, b) => b.t - a.t);
entries.slice(MAX_CACHE_ENTRY_LOW).map(a => sessionRemove(a.key));
sessionWrite('scripting.manager.cleanup.time', now)
}
/******************************************************************************/

View file

@ -21,7 +21,7 @@
// Important!
// Isolate from global scope
(function uBOL_cssProcedural() {
(async function uBOL_cssProcedural() {
/******************************************************************************/
@ -30,49 +30,13 @@ self.proceduralImports = undefined;
/******************************************************************************/
const isolatedAPI = self.isolatedAPI;
const selectors = new Set();
const exceptions = new Set();
const lookupHostname = (hostname, details) => {
const listref = isolatedAPI.binarySearch(details.hostnames, hostname);
if ( listref === -1 ) { return; }
if ( Array.isArray(details.selectorLists) === false ) {
details.selectorLists = details.selectorLists.split(';');
details.selectorListRefs = JSON.parse(`[${details.selectorListRefs}]`);
}
const ilist = details.selectorListRefs[listref];
const list = JSON.parse(`[${details.selectorLists[ilist]}]`);
for ( const iselector of list ) {
if ( iselector >= 0 ) {
selectors.add(details.selectors[iselector]);
} else {
exceptions.add(details.selectors[~iselector]);
}
}
};
const lookupAll = hostname => {
for ( const details of proceduralImports ) {
lookupHostname(hostname, details);
}
};
isolatedAPI.forEachHostname(lookupAll, {
hasEntities: proceduralImports.some(a => a.hasEntities)
});
const selectors = await self.cosmeticAPI.getSelectors('procedural', proceduralImports);
self.cosmeticAPI.release();
if ( selectors.length === 0 ) { return; }
proceduralImports.length = 0;
for ( const selector of exceptions ) {
selectors.delete(selector);
}
if ( selectors.size === 0 ) { return; }
const exceptedSelectors = Array.from(selectors).map(a => JSON.parse(a));
const declaratives = exceptedSelectors.filter(a => a.cssable);
const declaratives = selectors.filter(a => a.cssable);
if ( declaratives.length !== 0 ) {
const cssRuleFromProcedural = details => {
const { tasks, action } = details;
@ -112,7 +76,7 @@ if ( declaratives.length !== 0 ) {
}
}
const procedurals = exceptedSelectors.filter(a => a.cssable === undefined);
const procedurals = selectors.filter(a => a.cssable === undefined);
if ( procedurals.length !== 0 ) {
const addSelectors = selectors => {
if ( self.listsProceduralFiltererAPI instanceof Object === false ) { return; }

View file

@ -21,7 +21,7 @@
// Important!
// Isolate from global scope
(function uBOL_cssSpecific() {
(async function uBOL_cssSpecific() {
/******************************************************************************/
@ -30,48 +30,10 @@ self.specificImports = undefined;
/******************************************************************************/
const isolatedAPI = self.isolatedAPI;
const selectors = new Set();
const exceptions = new Set();
const lookupHostname = (hostname, details) => {
const listref = isolatedAPI.binarySearch(details.hostnames, hostname);
if ( listref === -1 ) { return; }
if ( Array.isArray(details.selectorLists) === false ) {
details.selectorLists = details.selectorLists.split(';');
details.selectorListRefs = JSON.parse(`[${details.selectorListRefs}]`);
}
const ilist = details.selectorListRefs[listref];
const list = JSON.parse(`[${details.selectorLists[ilist]}]`);
for ( const iselector of list ) {
if ( iselector >= 0 ) {
selectors.add(details.selectors[iselector]);
} else {
exceptions.add(details.selectors[~iselector]);
}
}
};
const lookupAll = hostname => {
for ( const details of specificImports ) {
lookupHostname(hostname, details);
}
};
isolatedAPI.forEachHostname(lookupAll, {
hasEntities: specificImports.some(a => a.hasEntities)
});
specificImports.length = 0;
for ( const selector of exceptions ) {
selectors.delete(selector);
}
if ( selectors.size === 0 ) { return; }
const css = `${Array.from(selectors).join(',\n')}{display:none!important;}`;
self.cssAPI.insert(css);
const selectors = await self.cosmeticAPI.getSelectors('specific', specificImports);
self.cosmeticAPI.release();
if ( selectors.length === 0 ) { return; }
self.cssAPI.insert(`${selectors.join(',\n')}{display:none!important;}`);
/******************************************************************************/

View file

@ -74,7 +74,38 @@
}
};
isolatedAPI.binarySearch = (sorted, target) => {
})(self.isolatedAPI);
(api => {
if ( typeof api === 'object' ) { return; }
const cosmeticAPI = self.cosmeticAPI = {};
const sessionRead = async function(key) {
try {
const bin = await chrome.storage.session.get(key);
return bin?.[key] ?? undefined;
} catch {
}
};
const sessionWrite = function(key, data) {
try {
chrome.storage.session.set({ [key]: data });
} catch {
}
};
const localRead = async function(key) {
try {
const bin = await chrome.storage.local.get(key);
return bin?.[key] ?? undefined;
} catch {
}
};
const binarySearch = (sorted, target) => {
let l = 0, i = 0, d = 0;
let r = sorted.length;
let candidate;
@ -95,7 +126,79 @@
return -1;
};
})(self.isolatedAPI);
const lookupHostname = (hostname, data) => {
const listref = binarySearch(data.hostnames, hostname);
if ( listref === -1 ) { return; }
const ilist = data.selectorListRefs[listref];
const list = JSON.parse(`[${data.selectorLists[ilist]}]`);
const { result } = data;
for ( const iselector of list ) {
if ( iselector >= 0 ) {
result.selectors.add(data.selectors[iselector]);
} else {
result.exceptions.add(data.selectors[~iselector]);
}
}
};
const selectorsFromRuleset = async (realm, rulesetId, result) => {
const data = await localRead(`css.${realm}.${rulesetId}`);
if ( typeof data !== 'object' || data === null ) { return; }
data.result = result;
self.isolatedAPI.forEachHostname(lookupHostname, data);
};
const fillCache = async function(realm, rulesetIds) {
const selectors = new Set();
const exceptions = new Set();
const result = { selectors, exceptions };
await Promise.all(rulesetIds.map(a => selectorsFromRuleset(realm, a, result)));
for ( const selector of exceptions ) {
selectors.delete(selector);
}
cacheEntry[cacheSlots[realm]] = Array.from(selectors).map(a =>
a.startsWith('{') ? JSON.parse(a) : a
);
};
const readCache = async ( ) => {
cacheEntry = await sessionRead(cacheKey) || {};
};
const cacheSlots = { 'specific': 's', 'procedural': 'p' };
const cacheKey = `cache.css.${document.location.hostname || ''}`;
let clientCount = 0;
let cacheEntry;
cosmeticAPI.getSelectors = async function(realm, rulesetIds) {
clientCount += 1;
const slot = cacheSlots[realm];
if ( cacheEntry === undefined ) {
cacheEntry = readCache();
}
if ( cacheEntry instanceof Promise ) {
await cacheEntry;
}
if ( cacheEntry[slot] === undefined ) {
cacheEntry[slot] = fillCache(realm, rulesetIds);
}
if ( cacheEntry[slot] instanceof Promise ) {
await cacheEntry[slot];
}
return cacheEntry[slot];
};
cosmeticAPI.release = function() {
clientCount -= 1;
if ( clientCount !== 0 ) { return; }
self.cosmeticAPI = undefined;
const now = Math.round(Date.now() / 15000);
const since = now - (cacheEntry.t || 0);
if ( since <= 1 ) { return; }
cacheEntry.t = now;
sessionWrite(cacheKey, cacheEntry);
};
})(self.cosmeticAPI);
/******************************************************************************/

View file

@ -863,38 +863,28 @@ async function processCosmeticFilters(assetDetails, realm, mapin) {
allHostnames.set(hn, allSelectorLists.get(list));
}
// The cosmetic filters will be injected programmatically as content
// script and the decisions to activate the cosmetic filters will be
// done at injection time according to the document's hostname.
const originalScriptletMap = await loadAllSourceScriptlets();
let patchedScriptlet = originalScriptletMap.get(`css-${realm}`).replace(
'$rulesetId$',
assetDetails.id
);
patchedScriptlet = safeReplace(patchedScriptlet,
/\bself\.\$selectors\$/,
`/* ${allSelectors.size} */ ${JSON.stringify(Array.from(allSelectors.keys()))}`
);
patchedScriptlet = safeReplace(patchedScriptlet,
/\bself\.\$selectorLists\$/,
`/* ${allSelectorLists.size} */ ${JSON.stringify(Array.from(allSelectorLists.keys()).join(';'))}`
);
const sortedHostnames = Array.from(allHostnames.keys()).toSorted((a, b) => {
const d = a.length - b.length;
if ( d !== 0 ) { return d; }
return a < b ? -1 : 1;
});
patchedScriptlet = safeReplace(patchedScriptlet,
/\bself\.\$selectorListRefs\$/,
`/* ${sortedHostnames.length} */ "${JSON.stringify(sortedHostnames.map(a => allHostnames.get(a))).slice(1, -1)}"`
);
patchedScriptlet = safeReplace(patchedScriptlet,
/\bself\.\$hostnames\$/,
`/* ${sortedHostnames.length} */ ${JSON.stringify(sortedHostnames)}`
);
patchedScriptlet = safeReplace(patchedScriptlet,
'self.$hasEntities$',
JSON.stringify(hasEntities)
const data = JSON.stringify({
selectors: Array.from(allSelectors.keys()),
selectorLists: Array.from(allSelectorLists.keys()),
selectorListRefs: sortedHostnames.map(a => allHostnames.get(a)),
hostnames: sortedHostnames,
hasEntities,
});
writeFile(`${scriptletDir}/${realm}/${assetDetails.id}.json`, data);
// The cosmetic filters will be injected programmatically as content
// script and the decisions to activate the cosmetic filters will be
// done at injection time according to the document's hostname.
const originalScriptletMap = await loadAllSourceScriptlets();
let patchedScriptlet = originalScriptletMap.get(`css-${realm}`).replace(
'self.$rulesetId$',
JSON.stringify(assetDetails.id)
);
writeFile(`${scriptletDir}/${realm}/${assetDetails.id}.js`, patchedScriptlet);

View file

@ -19,23 +19,16 @@
Home: https://github.com/gorhill/uBlock
*/
// ruleset: $rulesetId$
// Important!
// Isolate from global scope
(function uBOL_cssProceduralImport() {
/******************************************************************************/
const selectors = self.$selectors$;
const selectorLists = self.$selectorLists$;
const selectorListRefs = self.$selectorListRefs$;
const hostnames = self.$hostnames$;
const hasEntities = self.$hasEntities$;
const rulesetId = self.$rulesetId$;
self.proceduralImports = self.proceduralImports || [];
self.proceduralImports.push({ selectors, selectorLists, selectorListRefs, hostnames, hasEntities });
self.proceduralImports.push(rulesetId);
/******************************************************************************/

View file

@ -19,22 +19,16 @@
Home: https://github.com/gorhill/uBlock
*/
// ruleset: $rulesetId$
// Important!
// Isolate from global scope
(function uBOL_cssSpecificImports() {
/******************************************************************************/
const selectors = self.$selectors$;
const selectorLists = self.$selectorLists$;
const selectorListRefs = self.$selectorListRefs$;
const hostnames = self.$hostnames$;
const hasEntities = self.$hasEntities$;
const rulesetId = self.$rulesetId$;
self.specificImports = self.specificImports || [];
self.specificImports.push({ selectors, selectorLists, selectorListRefs, hostnames, hasEntities });
self.specificImports.push(rulesetId);
/******************************************************************************/