fix: handle extension ports across BFCache navigation

This commit is contained in:
syrup 2026-09-02 09:48:27 +08:00
parent 0b99643100
commit 793c343fb6
6 changed files with 70 additions and 3 deletions

View File

@ -11,8 +11,9 @@
"dev:web": "vite",
"dev:background": "npm run build:background -- --mode development",
"dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js",
"build-firefox": "cross-env NODE_ENV=production EXTENSION=firefox run-s clear-firefox build:web build:prepare build:background build:js",
"build": "cross-env NODE_ENV=production run-s clear build:bridge build:web build:prepare build:background build:js",
"build-firefox": "cross-env NODE_ENV=production EXTENSION=firefox run-s clear-firefox build:bridge build:web build:prepare build:background build:js",
"build:bridge": "npm --prefix webext-bridge run build",
"build:prepare": "esno scripts/prepare.ts",
"build:background": "vite build --config vite.config.background.mts",
"build:web": "vite build",
@ -34,6 +35,8 @@
"test:extension-runtime": "node scripts/verify-extension-runtime.mjs",
"test:remote-endpoint": "esno scripts/test-remote-endpoint.ts",
"test:production-endpoint": "node scripts/verify-production-endpoint.mjs",
"test:port-disconnect": "esno scripts/test-port-disconnect.ts",
"test:port-disconnect-build": "node scripts/verify-port-disconnect-build.mjs",
"prepare": "husky"
},
"devDependencies": {

View File

@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { isBackForwardCachePortError } from '../webext-bridge/src/internal/port-disconnect';
assert.equal(
isBackForwardCachePortError(
'The page keeping the extension port is moved into back/forward cache, so the message channel is closed.',
),
true,
);
assert.equal(isBackForwardCachePortError('Extension context invalidated.'), false);
assert.equal(isBackForwardCachePortError(), false);
console.log('Port disconnect tests passed.');

View File

@ -0,0 +1,16 @@
import { readFile } from 'node:fs/promises';
const background = await readFile('extension/dist/background/index.mjs', 'utf8');
const contentScript = await readFile('extension/dist/contentScripts/index.global.js', 'utf8');
if (!background.includes('lastError')) {
throw new Error('Background bundle does not consume runtime.lastError on port disconnect.');
}
if (!contentScript.includes('back/forward cache')) {
throw new Error('Content script bundle does not recognize BFCache port closures.');
}
if (!contentScript.includes('pagehide') || !contentScript.includes('pageshow')) {
throw new Error('Content script bundle does not suspend and resume the BFCache port lifecycle.');
}
console.log('Production BFCache port handling verification passed.');

View File

@ -279,6 +279,10 @@ browser.runtime.onConnect.addListener((incomingPort) => {
});
incomingPort.onDisconnect.addListener(() => {
// Reading lastError prevents Chrome from reporting an expected BFCache port closure as
// an unchecked runtime error in the extension service worker console.
void browser.runtime.lastError;
// sometimes previous content script's onDisconnect is called *after* the fresh content-script's
// onConnect. So without this fingerprint equality check, we would remove the new port from map
if (connMap.get(connArgs.endpointName)?.fingerprint === connArgs.fingerprint)

View File

@ -7,6 +7,7 @@ import { encodeConnectionArgs } from './connection-args';
import { createDeliveryLogger } from './delivery-logger';
import type { StatusMessage } from './port-message';
import { PortMessage } from './port-message';
import { isBackForwardCachePortError } from './port-disconnect';
/**
* Manfiest V3 extensions can have their service worker terminated at any point
@ -18,6 +19,8 @@ import { PortMessage } from './port-message';
export const createPersistentPort = (name = '') => {
const fingerprint = createFingerprint();
let port: Runtime.Port;
let connected = false;
let inBackForwardCache = false;
let undeliveredQueue: ReadonlyArray<QueuedMessage> = [];
const pendingResponses = createDeliveryLogger();
const onMessageListeners = new Set<(message: InternalMessage, port: Runtime.Port) => void>();
@ -77,14 +80,28 @@ export const createPersistentPort = (name = '') => {
};
const connect = () => {
if (connected || inBackForwardCache) return;
port = browser.runtime.connect({
name: encodeConnectionArgs({
endpointName: name,
fingerprint,
}),
});
connected = true;
const connectedPort = port;
port.onMessage.addListener(handleMessage);
port.onDisconnect.addListener(connect);
port.onDisconnect.addListener(() => {
const disconnectMessage = browser.runtime.lastError?.message;
if (port !== connectedPort) return;
connected = false;
if (isBackForwardCachePortError(disconnectMessage)) {
inBackForwardCache = true;
return;
}
connect();
});
PortMessage.toBackground(port, {
type: 'sync',
@ -95,6 +112,17 @@ export const createPersistentPort = (name = '') => {
});
};
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', (event) => {
if (event.persisted) inBackForwardCache = true;
});
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return;
inBackForwardCache = false;
connect();
});
}
connect();
return {

View File

@ -0,0 +1,3 @@
export function isBackForwardCachePortError(message?: string): boolean {
return message?.includes('back/forward cache') ?? false;
}