diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fee1ee22..ea18706f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -963,6 +963,12 @@ "optionsAdvancedNewStyleAsUsercss": { "message": "Write new style as usercss" }, + "optionsAdvancedStyleViaXhr": { + "message": "Instant inject mode" + }, + "optionsAdvancedStyleViaXhrNote": { + "message": "Enable this if you encounter flashing of unstyled content (FOUC) when browsing, which is especially noticeable with dark themes.\n\nThe technical reason is that Chrome/Chromium postpones asynchronous communication of extensions, in a usually meaningless attempt to improve page load speed, potentially causing styles to be late to apply. To circumvent this, since web extensions are not provided a synchronous API, Stylus provides this option to utilize the \"deprecated\" synchronous XMLHttpRequest web API to fetch applicable styles. There shouldn't be any detrimental effects, since the request is fulfilled within a few milliseconds while the page is still being downloaded from the server.\n\nNevertheless, Chromium will print a warning in devtools' console. Right-clicking a warning, and hiding them, will prevent future warnings from being shown." + }, "optionsBadgeDisabled": { "message": "Background color when disabled" }, diff --git a/background/style-via-xhr.js b/background/style-via-xhr.js new file mode 100644 index 00000000..a278bd0a --- /dev/null +++ b/background/style-via-xhr.js @@ -0,0 +1,85 @@ +/* global API CHROME prefs */ +'use strict'; + +// eslint-disable-next-line no-unused-expressions +CHROME && (async () => { + const prefId = 'styleViaXhr'; + const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/'); + const stylesToPass = {}; + + await prefs.initializing; + toggle(prefId, prefs.get(prefId)); + prefs.subscribe([prefId], toggle); + + function toggle(key, value) { + if (!chrome.declarativeContent) { // not yet granted in options page + value = false; + } + if (value) { + const reqFilter = { + urls: [''], + types: ['main_frame', 'sub_frame'], + }; + chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter); + chrome.webRequest.onHeadersReceived.addListener(passStyles, reqFilter, [ + 'blocking', + 'responseHeaders', + chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS, + ].filter(Boolean)); + } else { + chrome.webRequest.onBeforeRequest.removeListener(prepareStyles); + chrome.webRequest.onHeadersReceived.removeListener(passStyles); + } + if (!chrome.declarativeContent) { + return; + } + chrome.declarativeContent.onPageChanged.removeRules([prefId], async () => { + if (!value) return; + chrome.declarativeContent.onPageChanged.addRules([{ + id: prefId, + conditions: [ + new chrome.declarativeContent.PageStateMatcher({ + pageUrl: {urlContains: ':'}, + }), + ], + actions: [ + new chrome.declarativeContent.RequestContentScript({ + allFrames: true, + // This runs earlier than document_start + js: chrome.runtime.getManifest().content_scripts[0].js, + }), + ], + }]); + }); + } + + /** @param {chrome.webRequest.WebRequestBodyDetails} req */ + function prepareStyles(req) { + API.getSectionsByUrl(req.url).then(sections => { + const str = JSON.stringify(sections); + if (str !== '{}') { + stylesToPass[req.requestId] = URL.createObjectURL(new Blob([str])).slice(blobUrlPrefix.length); + setTimeout(cleanUp, 600e3, req.requestId); + } + }); + } + + /** @param {chrome.webRequest.WebResponseHeadersDetails} req */ + function passStyles(req) { + const blobId = stylesToPass[req.requestId]; + if (blobId) { + const {responseHeaders} = req; + responseHeaders.push({ + name: 'Set-Cookie', + value: `${chrome.runtime.id}=${blobId}`, + }); + return {responseHeaders}; + } + } + + function cleanUp(key) { + const blobId = stylesToPass[key]; + delete stylesToPass[key]; + if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId); + } +})(); diff --git a/content/apply.js b/content/apply.js index 555535a2..1d378347 100644 --- a/content/apply.js +++ b/content/apply.js @@ -20,6 +20,7 @@ self.INJECTED !== 1 && (() => { /** @type chrome.runtime.Port */ let port; let lazyBadge = IS_FRAME; + let parentDomain; // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason if (!IS_TAB) { @@ -42,24 +43,39 @@ self.INJECTED !== 1 && (() => { window.addEventListener(orphanEventId, orphanCheck, true); } - let parentDomain; - - prefs.subscribe(['disableAll'], (key, value) => doDisableAll(value)); - if (IS_FRAME) { - prefs.subscribe(['exposeIframes'], updateExposeIframes); - } - function onInjectorUpdate() { if (!isOrphaned) { updateCount(); - updateExposeIframes(); + const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe']; + onOff(['disableAll'], updateDisableAll); + if (IS_FRAME) { + updateExposeIframes(); + onOff(['exposeIframes'], updateExposeIframes); + } } } - function init() { - return STYLE_VIA_API ? - API.styleViaAPI({method: 'styleApply'}) : - API.getSectionsByUrl(getMatchUrl()).then(styleInjector.apply); + async function init() { + if (STYLE_VIA_API) { + await API.styleViaAPI({method: 'styleApply'}); + } else { + const styles = chrome.app && getStylesViaXhr() || await API.getSectionsByUrl(getMatchUrl()); + await styleInjector.apply(styles); + } + } + + function getStylesViaXhr() { + if (new RegExp(`(^|\\s|;)${chrome.runtime.id}=\\s*([-\\w]+)\\s*(;|$)`).test(document.cookie)) { + const url = 'blob:' + chrome.runtime.getURL(RegExp.$2); + const xhr = new XMLHttpRequest(); + document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie + try { + xhr.open('GET', url, false); // synchronous + xhr.send(); + URL.revokeObjectURL(url); + return JSON.parse(xhr.response); + } catch (e) {} + } } function getMatchUrl() { @@ -138,7 +154,7 @@ self.INJECTED !== 1 && (() => { } } - function doDisableAll(disableAll) { + function updateDisableAll(key, disableAll) { if (STYLE_VIA_API) { API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}}); } else { @@ -146,22 +162,18 @@ self.INJECTED !== 1 && (() => { } } - function fetchParentDomain() { - return parentDomain ? - Promise.resolve() : - API.getTabUrlPrefix() - .then(newDomain => { - parentDomain = newDomain; - }); - } - - function updateExposeIframes() { - if (!prefs.get('exposeIframes') || window === parent || !styleInjector.list.length) { - document.documentElement.removeAttribute('stylus-iframe'); + async function updateExposeIframes(key, value = prefs.get('exposeIframes')) { + const attr = 'stylus-iframe'; + const el = document.documentElement; + if (!el) return; // got no styles so styleInjector didn't wait for + if (!value || !styleInjector.list.length) { + el.removeAttribute(attr); } else { - fetchParentDomain().then(() => { - document.documentElement.setAttribute('stylus-iframe', parentDomain); - }); + if (!parentDomain) parentDomain = await API.getTabUrlPrefix(); + // Check first to avoid triggering DOM mutation + if (el.getAttribute(attr) !== parentDomain) { + el.setAttribute(attr, parentDomain); + } } } diff --git a/js/prefs.js b/js/prefs.js index c7fb981c..36864dcd 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -12,6 +12,7 @@ self.prefs = self.INJECTED === 1 ? self.prefs : (() => { 'disableAll': false, // boss key 'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes 'newStyleAsUsercss': false, // create new style in usercss format + 'styleViaXhr': false, // early style injection to avoid FOUC // checkbox in style config dialog 'config.autosave': true, diff --git a/manifest.json b/manifest.json index a3fc105f..887160ee 100644 --- a/manifest.json +++ b/manifest.json @@ -23,6 +23,9 @@ "identity", "" ], + "optional_permissions": [ + "declarativeContent" + ], "background": { "scripts": [ "js/polyfill.js", @@ -53,6 +56,7 @@ "background/usercss-helper.js", "background/usercss-install-helper.js", "background/style-via-api.js", + "background/style-via-xhr.js", "background/search-db.js", "background/update.js", "background/openusercss-api.js" diff --git a/options.html b/options.html index ca5e0451..00f8c8b4 100644 --- a/options.html +++ b/options.html @@ -239,9 +239,25 @@
+