From b40849acadc262dd529a66ddd0030b2ba60cad4f Mon Sep 17 00:00:00 2001 From: eight Date: Sun, 10 Mar 2019 10:58:17 +0800 Subject: [PATCH] Refactor: rewrite style injector (#664) * Refactor: style injector/docRootObserver/docRewriteObserver * Fix: minor * Fix: disabled state * Fix: use evade * Fix: apply.js is broken in our pages * Fix: transition patch is broken * Fix: also check elements after the last userstyle * Fix: remove outdated FIXME. styleInjector.toggle now toggle all styles * Fix: call Object.keys twice * Add a fixme * Fix: typo * Add a fixme * Fix: don't argue for mutations generated by other extensions --- content/apply.js | 483 +++++++++++--------------------------- content/style-injector.js | 187 +++++++++++++++ edit.html | 1 + install-usercss.html | 1 + manage.html | 1 + manifest.json | 9 +- options.html | 1 + popup.html | 1 + 8 files changed, 340 insertions(+), 344 deletions(-) create mode 100644 content/style-injector.js diff --git a/content/apply.js b/content/apply.js index 307d812b..cd30cd1c 100644 --- a/content/apply.js +++ b/content/apply.js @@ -1,5 +1,5 @@ /* eslint no-var: 0 */ -/* global msg API prefs */ +/* global msg API prefs createStyleInjector */ /* exported APPLY */ 'use strict'; @@ -8,20 +8,31 @@ const APPLY = (() => { const CHROME = chrome.app ? parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]) : NaN; const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; - var ID_PREFIX = 'stylus-'; - var ROOT; - var isOwnPage = location.protocol.endsWith('-extension:'); - var disableAll = false; - var styleElements = new Map(); - var disabledElements = new Map(); - var docRewriteObserver; - var docRootObserver; + const IS_OWN_PAGE = location.protocol.endsWith('-extension:'); const setStyleContent = createSetStyleContent(); + const styleInjector = createStyleInjector({ + compare: (a, b) => a.id - b.id, + setStyleContent, + onUpdate: onInjectorUpdate + }); + const docRootObserver = createDocRootObserver({ + onChange: () => { + if (styleInjector.outOfOrder()) { + styleInjector.sort(); + return true; + } + } + }); + const docRewriteObserver = createDocRewriteObserver({ + onChange: () => { + docRootObserver.evade(styleInjector.sort); + } + }); const initializing = init(); msg.onTab(applyOnMessage); - if (!isOwnPage) { + if (!IS_OWN_PAGE) { window.dispatchEvent(new CustomEvent(chrome.runtime.id, { detail: pageObject({method: 'orphan'}) })); @@ -35,21 +46,33 @@ const APPLY = (() => { prefs.subscribe(['exposeIframes'], updateExposeIframes); } + function onInjectorUpdate() { + if (!IS_OWN_PAGE && styleInjector.list.length) { + docRewriteObserver.start(); + docRootObserver.start(); + } else { + docRewriteObserver.stop(); + docRootObserver.stop(); + } + updateCount(); + updateExposeIframes(); + } + function init() { if (STYLE_VIA_API) { return API.styleViaAPI({method: 'styleApply'}); } return API.getSectionsByUrl(getMatchUrl()) - .then(result => { - ROOT = document.documentElement; - applyStyles(result, () => { - // CSS transition bug workaround: since we insert styles asynchronously, - // the browsers, especially Firefox, may apply all transitions on page load - if ([...styleElements.values()].some(n => n.textContent.includes('transition'))) { - applyTransitionPatch(); - } - }); - }); + .then(result => + applyStyles(result) + .then(() => { + // CSS transition bug workaround: since we insert styles asynchronously, + // the browsers, especially Firefox, may apply all transitions on page load + if (styleInjector.list.some(s => s.code.includes('transition'))) { + applyTransitionPatch(); + } + }) + ); } function pageObject(target) { @@ -84,7 +107,7 @@ const APPLY = (() => { function checkPageScript() { if (!ready) { - ready = CHROME || isOwnPage || Event.prototype.getPreventDefault ? + ready = CHROME || IS_OWN_PAGE || Event.prototype.getPreventDefault ? Promise.resolve(false) : injectPageScript(); } return ready; @@ -190,23 +213,21 @@ const APPLY = (() => { switch (request.method) { case 'styleDeleted': - removeStyle(request.style); + styleInjector.remove(request.style.id); break; case 'styleUpdated': - if (request.codeIsUpdated === false) { - applyStyleState(request.style); - } else if (request.style.enabled) { + if (request.style.enabled) { API.getSectionsByUrl(getMatchUrl(), request.style.id) .then(sections => { if (!sections[request.style.id]) { - removeStyle(request.style); + styleInjector.remove(request.style.id); } else { applyStyles(sections); } }); } else { - removeStyle(request.style); + styleInjector.remove(request.style.id); } break; @@ -238,20 +259,11 @@ const APPLY = (() => { } } - function doDisableAll(disable = disableAll) { - if (!disable === !disableAll) { - return; - } - disableAll = disable; + function doDisableAll(disableAll) { if (STYLE_VIA_API) { API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}}); } else { - Array.prototype.forEach.call(document.styleSheets, stylesheet => { - if (stylesheet.ownerNode.matches(`style.stylus[id^="${ID_PREFIX}"]`) - && stylesheet.disabled !== disable) { - stylesheet.disabled = disable; - } - }); + styleInjector.toggle(!disableAll); } } @@ -266,7 +278,7 @@ const APPLY = (() => { } function updateExposeIframes() { - if (!prefs.get('exposeIframes') || window === parent || !styleElements.size) { + if (!prefs.get('exposeIframes') || window === parent || !styleInjector.list.length) { document.documentElement.removeAttribute('stylus-iframe'); } else { fetchParentDomain().then(() => { @@ -288,167 +300,47 @@ const APPLY = (() => { API.styleViaAPI({method: 'updateCount'}).catch(msg.ignoreError); return; } - let count = 0; - for (const id of styleElements.keys()) { - if (!disabledElements.has(id)) { - count++; - } - } // we have to send the tabId so we can't use `sendBg` that is used by `API` msg.send({ method: 'invokeAPI', name: 'updateIconBadge', - args: [count] + args: [styleInjector.list.length] }).catch(console.error); } - function applyStyleState({id, enabled}) { - const inCache = disabledElements.get(id) || styleElements.get(id); - const inDoc = document.getElementById(ID_PREFIX + id); - if (enabled) { - if (inDoc) { - return; - } else if (inCache) { - addStyleElement(inCache); - disabledElements.delete(id); - } else { - return API.getSectionsByUrl(getMatchUrl(), id) - .then(applyStyles); - } - } else { - if (inDoc) { - disabledElements.set(id, inDoc); - docRootObserver.evade(() => inDoc.remove()); - } + function rootReady() { + if (document.documentElement) { + return Promise.resolve(); } - updateCount(); - } - - function removeStyle({id}) { - const el = document.getElementById(ID_PREFIX + id); - if (el) { - docRootObserver.evade(() => el.remove()); - } - disabledElements.delete(id); - if (styleElements.delete(id)) { - updateCount(); - } - } - - function applyStyles(sections, done) { - if (!Object.keys(sections).length) { - if (done) { - done(); - } - return; - } - - if (!document.documentElement) { + return new Promise(resolve => { new MutationObserver((mutations, observer) => { if (document.documentElement) { observer.disconnect(); - applyStyles(sections, done); + resolve(); } }).observe(document, {childList: true}); - return; - } - - if (docRootObserver) { - docRootObserver.stop(); - } else { - initDocRootObserver(); - } - const pending = []; - for (const section of Object.values(sections)) { - pending.push(applySections(section.id, section.code.join(''))); - } - docRootObserver.firstStart(); - - if (!isOwnPage && !docRewriteObserver && styleElements.size) { - initDocRewriteObserver(); - } - - updateExposeIframes(); - updateCount(); - if (done) { - Promise.all(pending).then(done); - } + }); } - function applySections(id, code) { - let el = styleElements.get(id) || document.getElementById(ID_PREFIX + id); - if (el && CHROME < 3321) { - // workaround for Chrome devtools bug fixed in v65 - el.remove(); - el = null; - } - if (!el) { - if (document.documentElement instanceof SVGSVGElement) { - // SVG document style - el = document.createElementNS('http://www.w3.org/2000/svg', 'style'); - } else if (document instanceof XMLDocument) { - // XML document style - el = document.createElementNS('http://www.w3.org/1999/xhtml', 'style'); - } else { - // HTML document style; also works on HTML-embedded SVG - el = document.createElement('style'); - } - el.id = ID_PREFIX + id; - el.type = 'text/css'; - // SVG className is not a string, but an instance of SVGAnimatedString - el.classList.add('stylus'); - addStyleElement(el); - } - let settingStyle; - if (el.textContent !== code) { - settingStyle = setStyleContent(el, code); - } - styleElements.set(id, el); - disabledElements.delete(id); - return Promise.resolve(settingStyle); - } - - function addStyleElement(newElement) { - if (!ROOT) { - return; - } - let next; - const newStyleId = getStyleId(newElement); - for (const el of styleElements.values()) { - if (el.parentNode && !el.id.endsWith('-ghost') && getStyleId(el) > newStyleId) { - next = el.parentNode === ROOT ? el : null; - break; - } - } - if (next === newElement.nextElementSibling) { - return; - } - const insert = () => { - ROOT.insertBefore(newElement, next || null); - if (disableAll) { - newElement.disabled = true; - } - }; - if (docRootObserver) { - docRootObserver.evade(insert); - } else { - insert(); + function applyStyles(sections) { + const styles = Object.values(sections); + if (!styles.length) { + return Promise.resolve(); } + return rootReady().then(() => + docRootObserver.evade(() => + styleInjector.addMany( + styles.map(s => ({id: s.id, code: s.code.join('')})) + ) + ) + ); } function replaceAll(newStyles) { - const oldStyles = Array.prototype.slice.call( - document.querySelectorAll(`style.stylus[id^="${ID_PREFIX}"]`)); - oldStyles.forEach(el => (el.id += '-ghost')); - styleElements.clear(); - disabledElements.clear(); - applyStyles(newStyles); - const removeOld = () => oldStyles.forEach(el => el.remove()); - if (docRewriteObserver) { - docRootObserver.evade(removeOld); - } else { - removeOld(); - } + styleInjector.replaceAll( + Object.values(newStyles) + .map(s => ({id: s.id, code: s.code.join('')})) + ); } function applyTransitionPatch() { @@ -457,23 +349,23 @@ const APPLY = (() => { const className = chrome.runtime.id + '-transition-bug-fix'; const docId = document.documentElement.id ? '#' + document.documentElement.id : ''; document.documentElement.classList.add(className); - applySections(0, ` + const el = styleInjector.createStyle('transition-patch'); + // FIXME: this will trigger docRootObserver and cause a resort. We should + // move this function into style-injector. + document.documentElement.appendChild(el); + setStyleContent(el, ` ${docId}.${CSS.escape(className)}:root * { transition: none !important; } `) .then(() => { setTimeout(() => { - removeStyle({id: 0}); + el.remove(); document.documentElement.classList.remove(className); }); }); } - function getStyleId(el) { - return parseInt(el.id.substr(ID_PREFIX.length)); - } - function orphanCheck(e) { if (e && e.detail.method !== 'orphan') { return; @@ -483,181 +375,86 @@ const APPLY = (() => { } // In Chrome content script is orphaned on an extension update/reload // so we need to detach event listeners - [docRewriteObserver, docRootObserver].forEach(ob => ob && ob.disconnect()); + styleInjector.clear(); window.removeEventListener(chrome.runtime.id, orphanCheck, true); try { msg.off(applyOnMessage); } catch (e) {} } - function initDocRewriteObserver() { + function createDocRewriteObserver({onChange}) { // detect documentElement being rewritten from inside the script - docRewriteObserver = new MutationObserver(mutations => { - for (let m = mutations.length; --m >= 0;) { - const added = mutations[m].addedNodes; - for (let n = added.length; --n >= 0;) { - if (added[n].localName === 'html') { - reinjectStyles(); - return; - } - } + let root; + let observing = false; + let timer; + const observer = new MutationObserver(check); + return {start, stop}; + + function start() { + if (observing) return; + // detect dynamic iframes rewritten after creation by the embedder i.e. externally + root = document.documentElement; + timer = setTimeout(check); + observer.observe(document, {childList: true}); + observing = true; + } + + function stop() { + if (!observing) return; + clearTimeout(timer); + observer.disconnect(); + observing = false; + } + + function check() { + if (root !== document.documentElement) { + root = document.documentElement; + onChange(); } - }); - docRewriteObserver.observe(document, {childList: true}); - // detect dynamic iframes rewritten after creation by the embedder i.e. externally - setTimeout(() => { - if (document.documentElement !== ROOT) { - reinjectStyles(); - } - }); - // re-add styles if we detect documentElement being recreated - function reinjectStyles() { - if (!styleElements) { - orphanCheck(); - return; - } - ROOT = document.documentElement; - docRootObserver.stop(); - const imported = []; - for (const [id, el] of styleElements.entries()) { - const copy = document.importNode(el, true); - el.textContent += ' '; // invalidate CSSOM cache - imported.push([id, copy]); - addStyleElement(copy); - } - docRootObserver.start(); - styleElements = new Map(imported); } } - function initDocRootObserver() { - let lastRestorationTime = 0; - let restorationCounter = 0; + function createDocRootObserver({onChange}) { + let digest = 0; + let lastCalledTime = NaN; let observing = false; - let sorting = false; - let observer; - // allow any types of elements between ours, except for the following: - const ORDERED_TAGS = ['head', 'body', 'frameset', 'style', 'link']; - - init(); - return; - - function init() { - observer = new MutationObserver(sortStyleElements); - docRootObserver = {firstStart, start, stop, evade, disconnect: stop}; - setTimeout(sortStyleElements); - } - function firstStart() { - if (sortStyleMap()) { - sortStyleElements(); + const observer = new MutationObserver(() => { + if (digest) { + if (performance.now() - lastCalledTime > 1000) { + digest = 0; + } else if (digest > 5) { + throw new Error('The page keeps generating mutations. Skip the event.'); + } } - start(); - } + if (onChange()) { + digest++; + lastCalledTime = performance.now(); + } + }); + return {start, stop, evade}; + function start() { - if (!observing && ROOT && observer) { - observer.observe(ROOT, {childList: true}); - observing = true; - } + if (observing) return; + observer.observe(document.documentElement, {childList: true}); + observing = true; } + function stop() { - if (observing) { - observer.takeRecords(); - observer.disconnect(); - observing = false; - } - } - function evade(fn) { - const wasObserving = observing; - if (observing) { - stop(); - } - fn(); - if (wasObserving) { - start(); - } - } - function sortStyleMap() { - const list = []; - let prevStyleId = 0; - let needsSorting = false; - for (const entry of styleElements.entries()) { - list.push(entry); - const el = entry[1]; - const styleId = getStyleId(el); - el.styleId = styleId; - needsSorting |= styleId < prevStyleId; - prevStyleId = styleId; - } - if (needsSorting) { - styleElements = new Map(list.sort((a, b) => a[1].styleId - b[1].styleId)); - return true; - } - } - function sortStyleElements() { if (!observing) return; - let prevExpected = document.documentElement.lastElementChild; - while (prevExpected && isSkippable(prevExpected, true)) { - prevExpected = prevExpected.previousElementSibling; - } - if (!prevExpected) return; - for (const el of styleElements.values()) { - if (!isMovable(el)) { - continue; - } - while (true) { - const next = prevExpected.nextElementSibling; - if (next && isSkippable(next)) { - prevExpected = next; - } else if ( - next === el || - next === el.previousElementSibling && next || - moveAfter(el, next || prevExpected)) { - prevExpected = el; - break; - } else { - return; - } - } - } - if (sorting) { - sorting = false; - if (observer) observer.takeRecords(); - if (!restorationLimitExceeded()) { - start(); - } else { - setTimeout(start, 1000); - } - } + // FIXME: do we need this? + observer.takeRecords(); + observer.disconnect(); + observing = false; } - function isMovable(el) { - return el.parentNode || !disabledElements.has(getStyleId(el)); - } - function isSkippable(el, skipOwnStyles) { - return !ORDERED_TAGS.includes(el.localName) || - el.id.startsWith(ID_PREFIX) && - (skipOwnStyles || el.id.endsWith('-ghost')) && - el.localName === 'style' && - el.className === 'stylus'; - } - function moveAfter(el, expected) { - if (!sorting) { - sorting = true; - stop(); + + function evade(fn) { + if (!observing) { + return fn(); } - expected.insertAdjacentElement('afterend', el); - if (el.disabled !== disableAll) { - // moving an element resets its 'disabled' state - el.disabled = disableAll; - } - return true; - } - function restorationLimitExceeded() { - const t = performance.now(); - if (t - lastRestorationTime > 1000) { - restorationCounter = 0; - } - lastRestorationTime = t; - return ++restorationCounter > 5; + stop(); + const r = fn(); + start(); + return r; } } })(); diff --git a/content/style-injector.js b/content/style-injector.js new file mode 100644 index 00000000..ad7c02ba --- /dev/null +++ b/content/style-injector.js @@ -0,0 +1,187 @@ +/* exported createStyleInjector */ +'use strict'; + +function createStyleInjector({compare, setStyleContent, onUpdate}) { + const CHROME = chrome.app ? parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]) : NaN; + const PREFIX = 'stylus-'; + // styles are out of order if any of these elements is injected between them + const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']); + const list = []; + const table = new Map(); + let enabled = true; + return { + // manipulation + add, + addMany, + remove, + update, + clear, + replaceAll, + + // method + toggle, + sort, + + // state + outOfOrder, + list, + + // static util + createStyle + }; + + function outOfOrder() { + if (!list.length) { + return false; + } + let el = list[0].el; + if (el.parentNode !== document.documentElement) { + return true; + } + let i = 0; + while (el) { + if (i < list.length && el === list[i].el) { + i++; + } else if (ORDERED_TAGS.has(el.localName)) { + return true; + } + el = el.nextSibling; + } + // some styles are not injected to the document + return i < list.length; + } + + function addMany(styles) { + const pending = Promise.all(styles.map(_add)); + emitUpdate(); + return pending; + } + + function add(style) { + const pending = _add(style); + emitUpdate(); + return pending; + } + + function _add(style) { + if (table.has(style.id)) { + return update(style); + } + style.el = createStyle(style.id); + const pending = setStyleContent(style.el, style.code); + table.set(style.id, style); + const nextIndex = list.findIndex(i => compare(i, style) > 0); + if (nextIndex < 0) { + document.documentElement.appendChild(style.el); + list.push(style); + } else { + document.documentElement.insertBefore(style.el, list[nextIndex].el); + list.splice(nextIndex, 0, style); + } + // disabled flag is read-only when not attached to a document + style.el.disabled = !enabled; + return pending; + } + + function remove(id) { + _remove(id); + emitUpdate(); + } + + function _remove(id) { + const style = table.get(id); + if (!style) return; + table.delete(id); + list.splice(list.indexOf(style), 1); + style.el.remove(); + } + + function update({id, code}) { + const style = table.get(id); + if (style.code === code) return; + style.code = code; + // workaround for Chrome devtools bug fixed in v65 + // https://github.com/openstyles/stylus/commit/0fa391732ba8e35fa68f326a560fc04c04b8608b + let oldEl; + if (CHROME < 3321) { + oldEl = style.el; + oldEl.id = ''; + style.el = createStyle(id); + oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling); + style.el.disabled = !enabled; + } + return setStyleContent(style.el, code) + .then(() => oldEl && oldEl.remove()); + } + + function createStyle(id) { + let el; + if (document.documentElement instanceof SVGSVGElement) { + // SVG document style + el = document.createElementNS('http://www.w3.org/2000/svg', 'style'); + } else if (document instanceof XMLDocument) { + // XML document style + el = document.createElementNS('http://www.w3.org/1999/xhtml', 'style'); + } else { + // HTML document style; also works on HTML-embedded SVG + el = document.createElement('style'); + } + el.id = `${PREFIX}${id}`; + el.type = 'text/css'; + // SVG className is not a string, but an instance of SVGAnimatedString + el.classList.add('stylus'); + return el; + } + + function clear() { + for (const style of list) { + style.el.remove(); + } + list.length = 0; + table.clear(); + emitUpdate(); + } + + function toggle(_enabled) { + if (enabled === _enabled) return; + enabled = _enabled; + for (const style of list) { + style.el.disabled = !enabled; + } + } + + function sort() { + list.sort(compare); + for (const style of list) { + // moving an element resets its 'disabled' state + const disabled = style.el.disabled; + // FIXME: do we need this? + // const copy = document.importNode(el, true); + // el.textContent += ' '; // invalidate CSSOM cache + document.documentElement.appendChild(style.el); + style.el.disabled = disabled; + } + } + + function emitUpdate() { + if (onUpdate) { + onUpdate(); + } + } + + function replaceAll(styles) { + const added = new Set(styles.map(s => s.id)); + const removed = []; + for (const style of list) { + if (!added.has(style.id)) { + removed.push(style.id); + } + } + // FIXME: is it possible that `docRootObserver` breaks the process? + return Promise.all(styles.map(_add)) + .then(() => { + removed.forEach(_remove); + emitUpdate(); + }); + } +} diff --git a/edit.html b/edit.html index 9185bd42..ffa7be9a 100644 --- a/edit.html +++ b/edit.html @@ -74,6 +74,7 @@ + diff --git a/install-usercss.html b/install-usercss.html index 6b9ad478..c6a00b2c 100644 --- a/install-usercss.html +++ b/install-usercss.html @@ -18,6 +18,7 @@ + diff --git a/manage.html b/manage.html index ecf81d09..05f2a57a 100644 --- a/manage.html +++ b/manage.html @@ -152,6 +152,7 @@ + diff --git a/manifest.json b/manifest.json index 21374881..b8eab624 100644 --- a/manifest.json +++ b/manifest.json @@ -64,7 +64,14 @@ "run_at": "document_start", "all_frames": true, "match_about_blank": true, - "js": ["js/polyfill.js", "js/promisify.js", "js/msg.js", "js/prefs.js", "content/apply.js"] + "js": [ + "js/polyfill.js", + "js/promisify.js", + "js/msg.js", + "js/prefs.js", + "content/style-injector.js", + "content/apply.js" + ] }, { "matches": ["http://userstyles.org/*", "https://userstyles.org/*"], diff --git a/options.html b/options.html index bee5654e..66b3ea23 100644 --- a/options.html +++ b/options.html @@ -28,6 +28,7 @@ + diff --git a/popup.html b/popup.html index c9d4f720..adbcc13b 100644 --- a/popup.html +++ b/popup.html @@ -185,6 +185,7 @@ +