Переглянути джерело

fix: pre-rendering and bfcache

tophf 2 роки тому
батько
коміт
ed086ccbc9

+ 4 - 1
src/background/index.js

@@ -71,7 +71,7 @@ const commandsToSyncIfTruthy = [
   'CheckUpdate',
 ];
 
-async function handleCommandMessage({ cmd, data } = {}, src) {
+async function handleCommandMessage({ cmd, data, [kTop]: mode } = {}, src) {
   const func = hasOwnProperty(commands, cmd) && commands[cmd];
   if (!func) {
     throw new SafeError(`Unknown command: ${cmd}`);
@@ -86,6 +86,9 @@ async function handleCommandMessage({ cmd, data } = {}, src) {
     if (process.env.DEBUG) console.log('No src.tab, ignoring:', ...arguments);
     return;
   }
+  if (mode && src) {
+    src[kTop] = mode;
+  }
   try {
     const res = await func(data, src);
     if (commandsToSync.includes(cmd)

+ 9 - 10
src/background/utils/icon.js

@@ -2,7 +2,7 @@ import { i18n, ignoreChromeErrors, makeDataUri, noop } from '@/common';
 import { BLACKLIST } from '@/common/consts';
 import { nest, objectPick } from '@/common/object';
 import { postInitialize } from './init';
-import { addOwnCommands, addPublicCommands, forEachTab } from './message';
+import { addOwnCommands, forEachTab } from './message';
 import { getOption, hookOptions, setOption } from './options';
 import { popupTabs } from './popup-tracker';
 import { INJECT, reloadAndSkipScripts } from './preinject';
@@ -18,10 +18,6 @@ addOwnCommands({
   ),
 });
 
-addPublicCommands({
-  SetBadge: setBadge,
-});
-
 /** We don't set 19px because FF and Vivaldi scale it down to 16px instead of our own crisp 16px */
 const SIZES = [16, 32];
 /** Caching own icon to improve dashboard loading speed, as well as browserAction API
@@ -161,13 +157,14 @@ function resetBadgeData(tabId, isInjected) {
 }
 
 /**
- * @param {Object} params
- * @param {chrome.runtime.MessageSender} src
+ * @param {number[] | string} ids
+ * @param {boolean} reset
+ * @param {VMMessageSender} src
  */
-function setBadge({ [IDS]: ids, reset }, { tab, frameId }) {
+export function setBadge(ids, reset, { tab, [kFrameId]: frameId, [kTop]: isTop }) {
   const tabId = tab.id;
   const injectable = ids === SKIP_SCRIPTS ? SKIP_SCRIPTS : !!ids;
-  const data = (frameId || !reset) && badges[tabId] || resetBadgeData(tabId, injectable);
+  const data = !(reset && isTop) && badges[tabId] || resetBadgeData(tabId, injectable);
   if (ids && ids !== SKIP_SCRIPTS) {
     const { idMap, totalMap } = data;
     // uniques
@@ -178,7 +175,9 @@ function setBadge({ [IDS]: ids, reset }, { tab, frameId }) {
     totalMap[frameId] = ids.length;
     for (const id in totalMap) data.total += totalMap[id];
   }
-  data[INJECT] = injectable;
+  if (isTop) {
+    data[INJECT] = injectable;
+  }
   updateBadgeColor(tab, data);
   updateState(tab, data);
 }

+ 2 - 2
src/background/utils/notifications.js

@@ -19,7 +19,7 @@ addPublicCommands({
         silent,
       }
     });
-    const op = isFunction(onclick) ? onclick : src && [src.tab.id, src.frameId];
+    const op = isFunction(onclick) ? onclick : src && [src.tab.id, src[kFrameId]];
     if (op) openers[notificationId] = op;
     return notificationId;
   },
@@ -43,7 +43,7 @@ function notifyOpener(id, isClick) {
     if (isClick) op();
   } else if (op) {
     sendTabCmd(op[0], isClick ? 'NotificationClick' : 'NotificationClose', id, {
-      frameId: op[1],
+      [kFrameId]: op[1],
     });
   }
 }

+ 2 - 2
src/background/utils/popup-tracker.js

@@ -29,7 +29,7 @@ addPublicCommands({
     const key = getCacheKey(tabId);
     if (popupTabs[tabId]) return;
     Object.assign(data, await getData({ [IDS]: Object.keys(data[IDS]) }));
-    (cache.get(key) || cache.put(key, {}))[src.frameId] = [data, src];
+    (cache.get(key) || cache.put(key, {}))[src[kFrameId]] = [data, src];
   }
 });
 
@@ -43,7 +43,7 @@ postInitialize.push(() => {
 
 async function isInjectable(tabId, badgeData) {
   return badgeData[INJECT]
-    && await sendTabCmd(tabId, VIOLENTMONKEY, null, { frameId: 0 })
+    && await sendTabCmd(tabId, VIOLENTMONKEY, null, { [kFrameId]: 0 })
     || (
       await browser.tabs.executeScript(tabId, { code: '1', [RUN_AT]: 'document_start' })
       .catch(() => [])

+ 31 - 18
src/background/utils/preinject.js

@@ -5,7 +5,8 @@ import {
 import initCache from '@/common/cache';
 import { forEachEntry, forEachKey, forEachValue, mapEntry, objectSet } from '@/common/object';
 import ua from '@/common/ua';
-import { getScriptsByURL, CACHE_KEYS, PROMISE, REQ_KEYS, VALUE_IDS } from './db';
+import { CACHE_KEYS, getScriptsByURL, PROMISE, REQ_KEYS, VALUE_IDS } from './db';
+import { setBadge } from './icon';
 import { postInitialize } from './init';
 import { addOwnCommands, addPublicCommands } from './message';
 import { getOption, hookOptions } from './options';
@@ -15,7 +16,7 @@ import {
   S_CACHE, S_CACHE_PRE, S_CODE, S_CODE_PRE, S_REQUIRE_PRE, S_SCRIPT_PRE, S_VALUE, S_VALUE_PRE,
 } from './storage';
 import { clearStorageCache, onStorageChanged } from './storage-cache';
-import { tabsOnRemoved } from './tabs';
+import { getFrameDocId, getFrameDocIdAsObj, getFrameDocIdFromSrc, tabsOnRemoved } from './tabs';
 import { addValueOpener, clearValueOpener } from './values';
 
 let isApplied;
@@ -93,13 +94,17 @@ const normalizeScriptRealm = (custom, meta) => (
 const isContentRealm = (val, force) => (
   val === CONTENT || val === AUTO && force
 );
+/** @param {chrome.webRequest.WebRequestHeadersDetails | chrome.webRequest.WebResponseHeadersDetails} info */
+const isTopFrame = info => info.frameType === 'outermost_frame' || !info[kFrameId];
 
 const skippedTabs = {};
 export const reloadAndSkipScripts = async tab => {
   const tabId = tab.id;
   const bag = cache.get(getKey(tab.url, true));
+  const reg = bag && unregisterScriptFF(bag);
   skippedTabs[tabId] = 1;
-  if (bag) await unregisterScriptFF(bag);
+  if (reg) await reg;
+  clearFrameData(tabId);
   await browser.tabs.reload(tabId);
 };
 
@@ -135,11 +140,11 @@ addOwnCommands({
 addPublicCommands({
   /** @return {Promise<VMInjection>} */
   async GetInjected({ url, [FORCE_CONTENT]: forceContent, done }, src) {
-    const { frameId, tab } = src;
+    const { tab, [kFrameId]: frameId, [kTop]: isTop } = src;
+    const frameDoc = getFrameDocId(isTop, src[kDocumentId], frameId);
     const tabId = tab.id;
-    const isTop = !frameId;
     if (!url) url = src.url || tab.url;
-    clearFrameData(tabId, frameId);
+    clearFrameData(tabId, frameDoc);
     let skip = skippedTabs[tabId];
     if (skip > 0) { // first time loading the tab after skipScripts was invoked
       if (isTop) skippedTabs[tabId] = -1; // keeping a phantom for future iframes in this page
@@ -154,10 +159,10 @@ addPublicCommands({
     const scripts = inject[SCRIPTS];
     if (scripts) {
       triageRealms(scripts, bag[FORCE_CONTENT] || forceContent, tabId, frameId, bag);
-      addValueOpener(scripts, tabId, frameId);
+      addValueOpener(scripts, tabId, frameDoc);
     }
     if (popupTabs[tabId]) {
-      setTimeout(sendTabCmd, 0, tabId, 'PopupShown', true, { frameId });
+      setTimeout(sendTabCmd, 0, tabId, 'PopupShown', true, getFrameDocIdAsObj(frameDoc));
     }
     return isApplied
       ? !done && inject
@@ -169,23 +174,30 @@ addPublicCommands({
     [MORE]: moreKey,
     url,
   }, src) {
-    const { frameId, tab } = src;
+    const { tab, [kFrameId]: frameId } = src;
+    const isTop = src[kTop];
     const tabId = tab.id;
     injectContentRealm(items, tabId, frameId);
     if (!moreKey) return;
     if (!url) url = src.url || tab.url;
     const env = cache.get(moreKey)
-      || cache.put(moreKey, getScriptsByURL(url, !frameId))
+      || cache.put(moreKey, getScriptsByURL(url, isTop))
       || { [SCRIPTS]: [] }; // scripts got removed or the url got blacklisted after GetInjected
     const envCache = (env[PROMISE] ? await env[PROMISE] : env)[S_CACHE];
     const scripts = prepareScripts(env);
     triageRealms(scripts, forceContent, tabId, frameId);
-    addValueOpener(scripts, tabId, frameId);
+    addValueOpener(scripts, tabId, getFrameDocId(isTop, src[kDocumentId], frameId));
     return {
       [SCRIPTS]: scripts,
       [S_CACHE]: envCache,
     };
   },
+  Run({ [IDS]: ids, reset }, src) {
+    setBadge(ids, reset, src);
+    if (reset === 'bfcache' && +ids[0]) {
+      addValueOpener(ids, src.tab.id, getFrameDocIdFromSrc(src));
+    }
+  },
 });
 
 hookOptions(onOptionChanged);
@@ -271,9 +283,10 @@ function toggleFastFirefoxInject(enable) {
   }
 }
 
-/** @param {chrome.webRequest.WebRequestDetails} info */
-function onSendHeaders({ url, frameId, tabId }) {
-  const isTop = !frameId;
+/** @param {chrome.webRequest.WebRequestHeadersDetails} info */
+function onSendHeaders(info) {
+  const { url, tabId } = info;
+  const isTop = isTopFrame(info);
   const key = getKey(url, isTop);
   if (!cache.has(key) && !skippedTabs[tabId]) {
     prepare(key, url, isTop);
@@ -282,7 +295,7 @@ function onSendHeaders({ url, frameId, tabId }) {
 
 /** @param {chrome.webRequest.WebResponseHeadersDetails} info */
 function onHeadersReceived(info) {
-  const key = getKey(info.url, !info.frameId);
+  const key = getKey(info.url, isTopFrame(info));
   const bag = cache.get(key);
   // The INJECT data is normally already in cache if code and values aren't huge
   if (bag && !bag[FORCE_CONTENT] && bag[INJECT]?.[SCRIPTS] && !skippedTabs[info.tabId]) {
@@ -299,7 +312,7 @@ function onHeadersReceived(info) {
  * @param {chrome.webRequest.WebResponseHeadersDetails} info
  * @param {VMInjection.Bag} bag
  */
-function prepareXhrBlob({ [kResponseHeaders]: responseHeaders, tabId, frameId }, bag) {
+function prepareXhrBlob({ [kResponseHeaders]: responseHeaders, [kFrameId]: frameId, tabId }, bag) {
   triageRealms(bag[INJECT][SCRIPTS], bag[FORCE_CONTENT], tabId, frameId, bag);
   const blobUrl = URL.createObjectURL(new Blob([
     JSON.stringify(bag[INJECT]),
@@ -509,8 +522,8 @@ function injectContentRealm(toContent, tabId, frameId) {
     browser.tabs.executeScript(tabId, {
       code: scr[__CODE].join(''),
       [RUN_AT]: `document_${scr[RUN_AT]}`.replace('body', 'start'),
-      frameId,
-    }).then(scr.meta[UNWRAP] && (() => sendTabCmd(tabId, 'Run', id, { frameId })));
+      [kFrameId]: frameId,
+    }).then(scr.meta[UNWRAP] && (() => sendTabCmd(tabId, 'Run', id, { [kFrameId]: frameId })));
   }
 }
 

+ 9 - 6
src/background/utils/requests.js

@@ -7,24 +7,27 @@ import { addPublicCommands, commands } from './message';
 import {
   FORBIDDEN_HEADER_RE, VM_VERIFY, requests, toggleHeaderInjector, verify,
 } from './requests-core';
+import { getFrameDocIdFromSrc, getFrameDocIdAsObj } from './tabs';
 
 addPublicCommands({
   /**
    * @param {GMReq.Message.Web} opts
-   * @param {MessageSender} src
+   * @param {VMMessageSender} src
    * @return {Promise<void>}
    */
   HttpRequest(opts, src) {
-    const { tab: { id: tabId }, frameId } = src;
+    const tabId = src.tab.id;
+    const frameId = getFrameDocIdFromSrc(src);
+    const frameIdObj = getFrameDocIdAsObj(frameId);
     const { id, events } = opts;
     const cb = res => requests[id] && (
-      sendTabCmd(tabId, 'HttpRequested', res, { frameId })
+      sendTabCmd(tabId, 'HttpRequested', res, frameIdObj)
     );
     /** @type {GMReq.BG} */
     requests[id] = {
       id,
       tabId,
-      frameId,
+      [kFrameId]: frameId,
       xhr: new XMLHttpRequest(),
     };
     return httpRequest(opts, events, src, cb)
@@ -184,7 +187,7 @@ function xhrCallbackWrapper(req, events, blobbed, chunked, isJson) {
 /**
  * @param {GMReq.Message.Web} opts
  * @param {GMReq.EventType[]} events
- * @param {MessageSender} src
+ * @param {VMMessageSender} src
  * @param {function} cb
  * @returns {Promise<void>}
  */
@@ -265,7 +268,7 @@ function clearRequest({ id, coreId }) {
 export function clearRequestsByTabId(tabId, frameId) {
   requests::forEachValue(req => {
     if ((tabId == null || req.tabId === tabId)
-    && (!frameId || req.frameId === frameId)) {
+    && (!frameId || req[kFrameId] === frameId)) {
       commands.AbortRequest(req.id);
     }
   });

+ 13 - 2
src/background/utils/tabs.js

@@ -17,6 +17,17 @@ const NEWTAB_URL_RE = re`/
   )
 )$
 /x`;
+/** @returns {string|number} documentId for a pre-rendered top page, frameId otherwise */
+export const getFrameDocId = (isTop, docId, frameId) => (
+  isTop === 2 && docId || frameId
+);
+/** @param {VMMessageSender} src */
+export const getFrameDocIdFromSrc = src => (
+  src[kTop] === 2 && src[kDocumentId] || src[kFrameId]
+);
+export const getFrameDocIdAsObj = id => +id >= 0
+  ? { [kFrameId]: +id }
+  : { [kDocumentId]: id };
 /**
  * @param {chrome.tabs.Tab} tab
  * @returns {string}
@@ -32,7 +43,7 @@ addOwnCommands({
   /**
    * @param {string} [pathId] - path or id: added to #scripts/ route in dashboard,
    * falsy: creates a new script for active tab's URL
-   * @param {chrome.runtime.MessageSender} [src]
+   * @param {VMMessageSender} [src]
    */
   async OpenEditor(pathId, src) {
     return commands.Dashboard(`${ROUTE_SCRIPTS_SLASH}${
@@ -41,7 +52,7 @@ addOwnCommands({
   },
   /**
    * @param {string} [route]
-   * @param {chrome.runtime.MessageSender} [src]
+   * @param {VMMessageSender} [src]
    */
   async Dashboard(route, src) {
     const url = extensionOptionsPage + (route || '');

+ 17 - 13
src/background/utils/values.js

@@ -2,7 +2,8 @@ import { isEmpty, sendTabCmd } from '@/common';
 import { forEachEntry, nest, objectGet, objectSet } from '@/common/object';
 import { getScript } from './db';
 import { addOwnCommands, addPublicCommands } from './message';
-import storage from './storage';
+import storage, { S_VALUE } from './storage';
+import { getFrameDocIdAsObj, getFrameDocIdFromSrc } from './tabs';
 
 /** { scriptId: { tabId: { frameId: {key: raw}, ... }, ... } } */
 const openers = {};
@@ -12,7 +13,7 @@ let toSend = {};
 addOwnCommands({
   async GetValueStore(id, { tab }) {
     const frames = nest(nest(openers, id), tab.id);
-    const values = frames[0] || (frames[0] = await storage.value.getOne(id));
+    const values = frames[0] || (frames[0] = await storage[S_VALUE].getOne(id));
     return values;
   },
   /**
@@ -37,8 +38,8 @@ addPublicCommands({
   /**
    * @return {?Promise<void>}
    */
-  UpdateValue({ id, key, raw }, { tab, frameId }) {
-    const values = objectGet(openers, [id, tab.id, frameId]);
+  UpdateValue({ id, key, raw }, src) {
+    const values = objectGet(openers, [id, src.tab.id, getFrameDocIdFromSrc(src)]);
     if (values) { // preventing the weird case of message arriving after the page navigated
       if (raw) values[key] = raw; else delete values[key];
       nest(toSend, id)[key] = raw || null;
@@ -69,20 +70,23 @@ export function clearValueOpener(tabId, frameId) {
 }
 
 /**
- * @param {VMInjection.Script[]} injectedScripts
+ * @param {VMInjection.Script[] | number[]} injectedScripts
  * @param {number} tabId
- * @param {number} frameId
+ * @param {number|string} frameId
  */
-export function addValueOpener(injectedScripts, tabId, frameId) {
-  injectedScripts.forEach(script => {
-    const { id, [VALUES]: values } = script;
+export async function addValueOpener(injectedScripts, tabId, frameId) {
+  const valuesById = +injectedScripts[0] // restoring storage for page from bfcache
+    && await storage[S_VALUE].getMulti(injectedScripts);
+  for (const script of injectedScripts) {
+    const id = valuesById ? script : script.id;
+    const values = valuesById ? valuesById[id] || null : script[VALUES];
     if (values) objectSet(openers, [id, tabId, frameId], Object.assign({}, values));
     else delete openers[id];
-  });
+  }
 }
 
 function commit(data) {
-  storage.value.set(data);
+  storage[S_VALUE].set(data);
   chain = chain.catch(console.warn).then(broadcast);
 }
 
@@ -94,7 +98,7 @@ async function broadcast() {
   for (const [tabId, frames] of Object.entries(toTabs)) {
     for (const [frameId, toFrame] of Object.entries(frames)) {
       if (!isEmpty(toFrame)) {
-        tasks.push(sendToFrame(+tabId, +frameId, toFrame));
+        tasks.push(sendToFrame(tabId, frameId, toFrame));
         if (tasks.length === 20) await Promise.all(tasks.splice(0)); // throttling
       }
     }
@@ -121,6 +125,6 @@ function groupByTab([id, valuesToSend]) {
 }
 
 function sendToFrame(tabId, frameId, data) {
-  return sendTabCmd(tabId, 'UpdatedValues', data, { frameId }).catch(console.warn);
+  return sendTabCmd(+tabId, 'UpdatedValues', data, getFrameDocIdAsObj(frameId)).catch(console.warn);
   // must use catch() to keep Promise.all going
 }

+ 1 - 2
src/common/index.js

@@ -47,7 +47,6 @@ export function initHooks() {
 }
 
 /**
- * Used by `injected`
  * @param {string} cmd
  * @param data
  * @param {{retry?: boolean}} [options]
@@ -101,7 +100,7 @@ export function sendCmdDirectly(cmd, data, options, fakeSrc) {
  * @param {number} tabId
  * @param {string} cmd
  * @param data
- * @param {{frameId?: number}} [options]
+ * @param {{frameId?: number} | {documentId?: string}} [options]
  * @return {Promise}
  */
 export function sendTabCmd(tabId, cmd, data, options) {

+ 1 - 0
src/common/safe-globals-shared.js

@@ -27,6 +27,7 @@ export const kResponseHeaders = 'responseHeaders';
 export const kResponseText = 'responseText';
 export const kResponseType = 'responseType';
 export const kSessionId = 'sessionId';
+export const kTop = 'top';
 export const kXhrType = 'xhrType';
 export const SKIP_SCRIPTS = 'SkipScripts';
 export const isFunction = val => typeof val === 'function';

+ 2 - 0
src/common/safe-globals.js

@@ -32,3 +32,5 @@ export const ICON_PREFIX = chrome.runtime.getURL(extensionManifest.icons[16].rep
 export const TAB_SETTINGS = 'settings';
 export const TAB_ABOUT = 'about';
 export const TAB_RECYCLE = 'recycleBin';
+export const kDocumentId = 'documentId';
+export const kFrameId = 'frameId';

+ 9 - 2
src/injected/content/bridge.js

@@ -15,6 +15,7 @@ const addHandlersImpl = (dest, src, force) => {
 /**
  * Without `force` handlers will be added only when userscripts are about to be injected.
  * { CommandName: true } will relay the request via sendCmd as is.
+ * { CommandName: REIFY } same as `true` but waits until reified when pre-rendered.
  * @param {Object.<string, MessageFromGuestHandler>} obj
  * @param {boolean} [force]
  */
@@ -24,6 +25,7 @@ export const addBackgroundHandlers = addHandlersImpl.bind({}, bgHandlers);
 /**
  * @property {VMBridgePostFunc} [post] - present only when the web bridge was initialized
  * @property {VMScriptInjectInto} [injectInto] - present only after GetInjected received data
+ * @property {Promise<void>} [reify] - present in pre-rendered documents, resolved when it's shown
  */
 const bridge = {
   __proto__: null,
@@ -33,13 +35,18 @@ const bridge = {
   pathMaps: createNullObj(),
   // realm is provided when called directly via invokeHost
   async onHandle({ cmd, data, node }, realm) {
-    const handle = handlers[cmd];
+    let res;
+    let handle = handlers[cmd];
     let callbackId = data && getOwnProp(data, CALLBACK_ID);
     if (callbackId) {
       data = data.data;
     }
-    let res;
     try {
+      if (handle === REIFY) {
+        handle = true;
+        res = bridge[REIFY];
+        if (res) await res;
+      }
       res = handle === true
         ? sendCmd(cmd, data)
         : node::handle(data, realm || PAGE);

+ 4 - 3
src/injected/content/clipboard.js

@@ -1,4 +1,4 @@
-import { addHandlers, onScripts } from './bridge';
+import bridge, { addHandlers, onScripts } from './bridge';
 
 export let onClipboardCopy;
 let doCopy;
@@ -22,7 +22,8 @@ onScripts.push(({ clipFF }) => {
       e::preventDefault();
       e::getClipboardData()::setData(clipboardData.type || 'text/plain', clipboardData.data);
     };
-    setClipboard = params => {
+    setClipboard = async params => {
+      await bridge[REIFY];
       clipboardData = params;
       if (!document::execCommand('copy') && process.env.DEBUG) {
         log('warn', null, 'GM_setClipboard failed!');
@@ -31,6 +32,6 @@ onScripts.push(({ clipFF }) => {
     };
   }
   addHandlers({
-    SetClipboard: setClipboard || true,
+    SetClipboard: setClipboard || REIFY,
   });
 });

+ 30 - 19
src/injected/content/cmd-run.js

@@ -1,45 +1,56 @@
 import bridge, { addHandlers, onScripts } from './bridge';
 import { sendSetPopup } from './gm-api-content';
-import { nextTask, sendCmd } from './util';
+import { nextTask, sendCmd, topRenderMode } from './util';
 
 const getPersisted = describeProperty(PageTransitionEvent[PROTO], 'persisted').get;
+let pending = topRenderMode === 2; // wait until reified if pre-rendered
+let resolveOnReify;
 let runningIds;
-let pending;
 let sent;
 
 onScripts.push(() => {
   addHandlers({ Run });
   runningIds = [];
 });
-on('pageshow', evt => {
+on('pageshow', onShown);
+if (pending) {
+  document::on('prerenderingchange', onShown.bind(null));
+  bridge[REIFY] = new Promise(resolve => (resolveOnReify = resolve));
+}
+
+function onShown(evt) {
   // isTrusted is `unforgeable` per DOM spec
-  if (evt.isTrusted && evt::getPersisted()) {
-    sent = false;
-    sendSetBadge();
+  if (evt.isTrusted) {
+    if (!this) {
+      sent = bridge[REIFY] = false;
+      resolveOnReify();
+      report();
+    } else if (evt::getPersisted()) {
+      report(0, 'bfcache');
+    }
   }
-});
+}
 
 export function Run(id, realm) {
+  if (id === SKIP_SCRIPTS) {
+    runningIds = SKIP_SCRIPTS;
+    report();
+    return;
+  }
   safePush(runningIds, id);
   bridge[IDS][id] = realm || PAGE;
-  if (!pending) pending = sendSetBadge(2);
+  if (!pending) pending = report(2);
 }
 
-export async function sendSetBadge(delayed) {
-  if (delayed === AUTO && (pending || sent)) {
-    return;
-  }
-  while (--delayed >= 0) {
-    await nextTask();
-  }
+async function report(delay, reset = !sent) {
+  while (--delay >= 0) await nextTask();
   // not awaiting to clear `pending` immediately
-  sendCmd('SetBadge', { [IDS]: runningIds, reset: !sent });
+  sendCmd('Run', { reset, [IDS]: runningIds });
   sendSetPopup(!!pending);
   pending = false;
   sent = true;
 }
 
-export function sendSkipScripts() {
-  runningIds = SKIP_SCRIPTS;
-  sendSetBadge();
+export function finish() {
+  if (!pending && !sent) report();
 }

+ 2 - 1
src/injected/content/gm-api-content.js

@@ -9,7 +9,8 @@ let setPopupThrottle;
 let isPopupShown;
 
 addBackgroundHandlers({
-  PopupShown(state) {
+  async PopupShown(state) {
+    await bridge[REIFY];
     isPopupShown = state;
     sendSetPopup();
   },

+ 5 - 5
src/injected/content/index.js

@@ -6,7 +6,7 @@ import './requests';
 import './tabs';
 import { sendCmd } from './util';
 import { isEmpty } from '../util';
-import { Run, sendSkipScripts, sendSetBadge } from './cmd-run';
+import { Run, finish } from './cmd-run';
 
 const { [IDS]: ids } = bridge;
 
@@ -36,7 +36,7 @@ async function init() {
     off('copy', onClipboardCopy, true);
   }
   if ((bridge[INJECT_INTO] = data[INJECT_INTO]) === SKIP_SCRIPTS) {
-    sendSkipScripts();
+    Run(SKIP_SCRIPTS);
     return;
   }
   if (data[EXPOSE] && !isXml && injectPageSandbox(data)) {
@@ -48,7 +48,7 @@ async function init() {
     await injectScripts(data, isXml);
   }
   onScripts.length = 0;
-  sendSetBadge(AUTO);
+  finish();
 }
 
 addBackgroundHandlers({
@@ -67,8 +67,8 @@ addBackgroundHandlers({
 });
 
 addHandlers({
-  TabFocus: true,
-  UpdateValue: true,
+  TabFocus: REIFY,
+  UpdateValue: REIFY,
 });
 
 init().catch(IS_FIREFOX && logging.error); // Firefox can't show exceptions in content scripts

+ 1 - 0
src/injected/content/notifications.js

@@ -6,6 +6,7 @@ const relay = (msg, n) => n && bridge.post(msg, n.id, n.realm) && n;
 
 addHandlers({
   async Notification(options, realm) {
+    await bridge[REIFY];
     const nid = await sendCmd('Notification', options);
     notifications[nid] = { id: options.id, realm };
   },

+ 1 - 0
src/injected/content/safe-globals.js

@@ -61,4 +61,5 @@ export const isPromise = (proto => val => isInstance(val, proto))(SafePromise[PR
  * The document's value can change only in about:blank but we don't inject there. */
 const { document } = global;
 export const { getElementsByTagName } = document;
+export const REIFY = 'reify';
 export let IS_FIREFOX = !chrome.app;

+ 3 - 1
src/injected/content/tabs.js

@@ -7,12 +7,14 @@ const realms = createNullObj();
 
 addHandlers({
   async TabOpen({ key, data }, realm) {
+    await bridge[REIFY];
     const { id } = await sendCmd('TabOpen', data);
     tabIds[key] = id;
     tabKeys[id] = key;
     realms[id] = realm;
   },
-  TabClose(key) {
+  async TabClose(key) {
+    await bridge[REIFY];
     const id = tabIds[key];
     // !key => close current tab
     // id => close tab by id

+ 14 - 1
src/injected/content/util.js

@@ -1,5 +1,6 @@
 // eslint-disable-next-line no-restricted-imports
-export { sendCmd } from '@/common';
+import { sendMessage } from '@/common';
+
 export * from './util-task';
 
 /** When looking for documentElement, use '*' to also support XML pages
@@ -70,3 +71,15 @@ export const decodeResource = (raw, isBlob) => {
     ? new SafeBlob([res], { type: mimeType })
     : res;
 };
+
+export const topRenderMode = window !== top ? 0 : document.prerendering ? 2 : 1;
+/**
+ * Used by `injected`
+ * @param {string} cmd
+ * @param data
+ * @param {{retry?: boolean}} [options]
+ * @return {Promise}
+ */
+export const sendCmd = (cmd, data, options) => (
+  sendMessage({ cmd, data, [kTop]: topRenderMode }, options)
+);

+ 2 - 2
src/injected/index.js

@@ -1,11 +1,11 @@
 import browser from '@/common/browser'; // eslint-disable-line no-restricted-imports
-import { sendCmd } from '@/common'; // eslint-disable-line no-restricted-imports
+import { sendCmd, topRenderMode } from './content/util';
 import { USERSCRIPT_META_INTRO } from './util';
 import './content';
 
 // Script installation in Firefox as it does not support `onBeforeRequest` for `file:`
 // Using pathname and a case-sensitive check to match webRequest `urls` filter behavior
-if (IS_FIREFOX && window === top
+if (IS_FIREFOX && topRenderMode === 1
 && location.protocol === 'file:'
 && location.pathname.endsWith('.user.js')
 && document.contentType === 'application/x-javascript' // FF uses this for file: scheme

+ 1 - 1
src/options/views/edit/values.vue

@@ -121,7 +121,7 @@ const flipPage = (vm, dir) => {
   vm.page = Math.max(1, Math.min(vm.totalPages, vm.page + dir));
 };
 /** Uses a negative tabId which is recognized in bg::values.js */
-const fakeSender = () => ({ tab: { id: Math.random() - 2 }, frameId: 0 });
+const fakeSender = () => ({ tab: { id: Math.random() - 2 }, [kFrameId]: 0 });
 const conditionNotEdit = { condition: '!edit' };
 
 export default {

+ 3 - 3
src/popup/index.js

@@ -14,13 +14,13 @@ initialize();
 render(App);
 
 Object.assign(handlers, {
-  SetBadge({ reset }, { frameId, tab }) {
-    // The tab got reloaded so SetBadge+reset comes right before SetPopup, see cmd-run.js
+  Run({ reset }, { [kFrameId]: frameId, tab }) {
+    // The tab got reloaded so Run+reset comes right before SetPopup, see cmd-run.js
     if (reset && !frameId && isMyTab(tab)) {
       initialize();
     }
   },
-  async SetPopup(data, { frameId, tab, url }) {
+  async SetPopup(data, { [kFrameId]: frameId, tab, url }) {
     if (!isMyTab(tab)) return;
     /* SetPopup from a sub-frame may come first so we need to wait for the main page
      * because we only show the iframe menu for unique scripts that don't run in the main page */

+ 6 - 0
src/types.d.ts

@@ -339,4 +339,10 @@ declare interface VMUserAgent extends VMScriptGMInfoPlatform {
   ready: Promise<void>;
 }
 
+/** Augmented by handleCommandMessage in messages from the content script */
+declare interface VMMessageSender extends chrome.runtime.MessageSender {
+  /** 0 = frame, 1 = top document, 2 = pre-rendered top document */
+  top?: number;
+}
+
 //#endregion Generic