From 42ea60e761758ce9a78457252f35dcf73ed125f6 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Mon, 21 Sep 2026 12:18:00 +0100 Subject: [PATCH] Recover when a page has no content script (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome only injects content scripts into pages loaded after the extension is installed, updated or reloaded, and Chrome updates extensions silently in the background. A tab left open for days — a dashboard, say — quietly loses its content script. Every later tabs.sendMessage then rejects with "Could not establish connection. Receiving end does not exist", the popup does nothing at all, and the rejection surfaces as an uncaught error in the extension's log. That is exactly what the reporter of #126 saw on Meta Ads Manager, and it explains "it worked in the past, and a year ago it stopped". The popup now injects the content script and retries instead of failing. Where the page genuinely cannot be reached — chrome://, the Web Store, a PDF viewer, a file:// URL without file access — it says so rather than leaving the spinner running forever waiting for a reply that is not coming. Co-Authored-By: Claude Opus 5 (1M context) --- _locales/en/messages.json | 8 ++- src/constants.ts | 10 +++ src/cypress/fixtures/example.json | 5 ++ src/popup.ts | 41 ++++++++++- src/popup/deliver.test.ts | 110 ++++++++++++++++++++++++++++++ src/popup/deliver.ts | 55 +++++++++++++++ src/testing/chromeMock.ts | 28 ++++++++ 7 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 src/cypress/fixtures/example.json create mode 100644 src/popup/deliver.test.ts create mode 100644 src/popup/deliver.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 8cc5b47..72eadeb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -28,8 +28,8 @@ "description": "about" }, "back": { - "message": "Back", - "description": "Back" + "message": "Back", + "description": "Back" }, "version": { "message": "Version", @@ -354,5 +354,9 @@ "hidden_content_note": { "message": "NOTE: When Hidden content? is checked, the extension will search through all elements regardless of their visibility, which may result in a higher match count.", "description": "Note about hidden content behavior" + }, + "pageUnreachable": { + "message": "Search and Replace cannot reach this page. Reload the page and try again.", + "description": "pageUnreachable" } } diff --git a/src/constants.ts b/src/constants.ts index b840716..01912d7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,6 +13,16 @@ export const RICH_TEXT_EDITORS = { */ export const ELEMENT_FILTER = /^(HTML|HEAD|SCRIPT|STYLE|IFRAME)$/i export const INPUT_TEXTAREA_CONTENT_EDITABLE_SELECTOR = 'input,textarea,*[contenteditable="true"]' + +/** The bundle listed under content_scripts in manifest.json, and injected on demand */ +export const CONTENT_SCRIPT_FILE = 'searchreplace.js' + +/** + * Chrome's wording when `tabs.sendMessage` finds nobody listening in the target tab, which + * happens when the page was loaded before the extension, or when the extension has been + * reloaded or auto-updated since the page was loaded, orphaning its content script. + */ +export const NO_RECEIVER_MESSAGE = 'Receiving end does not exist' export const HINTS: Record = { gmail: { hint: 'Hint: Gmail detected. Check "Input fields only?" when editing draft emails.', diff --git a/src/cypress/fixtures/example.json b/src/cypress/fixtures/example.json new file mode 100644 index 0000000..519902d --- /dev/null +++ b/src/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/src/popup.ts b/src/popup.ts index 5615160..32ab3af 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -28,6 +28,7 @@ import { manifest, tabConnect, } from './util' +import { deliverToContentScript } from './popup/deliver' import { getRemainingMatchCount } from './popup/count' // Enum for content types @@ -382,6 +383,11 @@ async function formSubmitHandler( await storeTerms(true) // do the search replace await contentScriptCall(action, searchReplaceInstance, historyItems) + if (!reachedContentScript) { + // No reply is coming, so take the spinner down and say why + removeLoader() + setPageUnreachableHint(translationFn) + } } /** Send the search and replace instance to the content script for processing, clearing any saved responses first. @@ -408,12 +414,27 @@ export async function contentScriptCall( url: tab.url, instanceId, } - await chrome.tabs.sendMessage(tab.id, message) + // Injects the content script and retries if the page does not have one. Never rejects + // for a missing content script, so a page the extension cannot reach leaves the popup + // usable instead of hanging on a spinner. + const delivery = await deliverToContentScript(tab.id, message) + if (delivery === 'no-content-script') { + reachedContentScript = false + return undefined + } + reachedContentScript = true url = tab.url } return url } +/** + * Whether the last contentScriptCall reached a content script. The response arrives + * asynchronously through chrome.runtime.onMessage, so the popup has to remember that the + * request never got anywhere in order to stop waiting for a reply. + */ +let reachedContentScript = true + /** The callback function for the content script * @param msg {SearchReplaceResponse} * @param translationFn @@ -467,6 +488,24 @@ function setHints(hints?: Hint[]) { } } +/** + * Tell the user the page cannot be reached, in the same place hints appear. Reached when a page + * has no content script and one cannot be injected — a chrome:// page, the Web Store, a PDF, or + * a file:// URL without file access. + */ +function setPageUnreachableHint(translationFn: TranslationProxy) { + const hintsElement = document.getElementById('hints') + if (!hintsElement) { + return + } + hintsElement.innerHTML = '' + const alert = document.createElement('div') + alert.className = 'hint alert alert-warning' + alert.setAttribute('role', 'alert') + alert.innerText = translationFn('pageUnreachable') + hintsElement.appendChild(alert) +} + function removeLoader() { const loader = document.getElementById('loader') if (loader) loader.style.display = 'none' diff --git a/src/popup/deliver.test.ts b/src/popup/deliver.test.ts new file mode 100644 index 0000000..78a3c9d --- /dev/null +++ b/src/popup/deliver.test.ts @@ -0,0 +1,110 @@ +import { ChromeMock, installChromeMock } from '../testing/chromeMock' +import { SearchReplaceContentMessage } from '../types' +import { beforeEach, describe, expect, test } from 'vitest' +import { deliverToContentScript, isMissingContentScript } from './deliver' + +const TAB_ID = 7 + +function message(): SearchReplaceContentMessage { + return { + action: 'searchReplace', + instance: { + searchTerm: 'find', + replaceTerm: 'replace', + options: { + matchCase: false, + inputFieldsOnly: false, + hiddenContent: false, + wholeWord: false, + isRegex: false, + replaceHTML: false, + replaceAll: true, + save: false, + }, + }, + } +} + +let mock: ChromeMock + +beforeEach(() => { + mock = installChromeMock() +}) + +describe('isMissingContentScript', () => { + test('recognises the error Chrome raises when nothing is listening', () => { + // The wording the reporter of issue #126 saw in the extension's error log + expect(isMissingContentScript(new Error('Could not establish connection. Receiving end does not exist.'))).toBe( + true + ) + }) + + test('does not swallow unrelated errors', () => { + expect(isMissingContentScript(new Error('The tab was closed'))).toBe(false) + expect(isMissingContentScript(new Error('Extension context invalidated'))).toBe(false) + }) + + test('copes with something thrown that is not an Error', () => { + expect(isMissingContentScript('Receiving end does not exist')).toBe(true) + expect(isMissingContentScript(undefined)).toBe(false) + }) +}) + +describe('deliverToContentScript', () => { + test('sends straight through when the content script is present', async () => { + expect(await deliverToContentScript(TAB_ID, message())).toBe('delivered') + expect(mock.sentTabMessages).toHaveLength(1) + // No need to inject when the page already has it + expect(mock.executedScripts).toEqual([]) + }) + + test('injects the content script and retries when nothing is listening', async () => { + mock.tabsWithoutContentScript.add(TAB_ID) + + expect(await deliverToContentScript(TAB_ID, message())).toBe('delivered-after-injecting') + expect(mock.executedScripts).toEqual([{ tabId: TAB_ID, files: ['searchreplace.js'] }]) + // The first attempt failed, the second succeeded + expect(mock.sentTabMessages).toHaveLength(1) + }) + + test('reports no content script when the page cannot be injected into', async () => { + // chrome://, the Web Store, a PDF viewer, or file:// without file access + mock.tabsWithoutContentScript.add(TAB_ID) + mock.blockedTabIds.add(TAB_ID) + + expect(await deliverToContentScript(TAB_ID, message())).toBe('no-content-script') + expect(mock.sentTabMessages).toEqual([]) + }) + + test('does not reject when the page cannot be reached, so the popup stays usable', async () => { + mock.tabsWithoutContentScript.add(TAB_ID) + mock.blockedTabIds.add(TAB_ID) + + await expect(deliverToContentScript(TAB_ID, message())).resolves.toBe('no-content-script') + }) + + test('reports no content script when injection succeeds but the retry still fails', async () => { + // The tab navigated away between injecting and sending + mock.tabsWithoutContentScript.add(TAB_ID) + mock.injectionDoesNotHelp.add(TAB_ID) + + expect(await deliverToContentScript(TAB_ID, message())).toBe('no-content-script') + expect(mock.executedScripts).toHaveLength(1) + }) + + test('passes other errors on rather than hiding them behind an injection', async () => { + mock.tabMessageError = new Error('Extension context invalidated') + + await expect(deliverToContentScript(TAB_ID, message())).rejects.toThrow('Extension context invalidated') + expect(mock.executedScripts).toEqual([]) + }) + + test('delivers the message unchanged', async () => { + mock.tabsWithoutContentScript.add(TAB_ID) + const msg = message() + + await deliverToContentScript(TAB_ID, msg) + + expect(mock.sentTabMessages[0]).toEqual({ tabId: TAB_ID, message: msg }) + }) +}) diff --git a/src/popup/deliver.ts b/src/popup/deliver.ts new file mode 100644 index 0000000..6176f4e --- /dev/null +++ b/src/popup/deliver.ts @@ -0,0 +1,55 @@ +import { CONTENT_SCRIPT_FILE, NO_RECEIVER_MESSAGE } from '../constants' +import { SearchReplaceContentMessage } from '../types' + +/** Whether a rejection from `tabs.sendMessage` means no content script was listening */ +export function isMissingContentScript(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes(NO_RECEIVER_MESSAGE) +} + +export type DeliveryResult = 'delivered' | 'delivered-after-injecting' | 'no-content-script' + +/** + * Send a message to the content script, injecting it first if it is not there. + * + * Chrome only injects content scripts into pages loaded *after* the extension is installed, + * updated or reloaded. Because Chrome updates extensions silently in the background, a + * long-lived tab — a dashboard someone leaves open for days — loses its content script without + * anything visibly happening. Every later `tabs.sendMessage` then rejects with "Receiving end + * does not exist", the popup does nothing at all, and the rejection surfaces as an uncaught + * error. That is the failure reported in issue #126 on Meta Ads Manager, and it explains a + * report of "it worked in the past, and a year ago it stopped". + * + * Rather than asking the user to reload, inject the content script and try again. + */ +export async function deliverToContentScript( + tabId: number, + message: SearchReplaceContentMessage +): Promise { + try { + await chrome.tabs.sendMessage(tabId, message) + return 'delivered' + } catch (error) { + if (!isMissingContentScript(error)) { + throw error + } + } + + try { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: [CONTENT_SCRIPT_FILE], + }) + } catch { + // A page the extension has no access to: chrome://, the Web Store, a PDF viewer, or a + // file:// URL without file access granted + return 'no-content-script' + } + + try { + await chrome.tabs.sendMessage(tabId, message) + return 'delivered-after-injecting' + } catch { + return 'no-content-script' + } +} diff --git a/src/testing/chromeMock.ts b/src/testing/chromeMock.ts index 37c20d9..d1986f9 100644 --- a/src/testing/chromeMock.ts +++ b/src/testing/chromeMock.ts @@ -94,6 +94,12 @@ export interface ChromeMock { executedScripts: { tabId: number; files: string[] }[] /** Tab ids for which `chrome.scripting.executeScript` should reject, as restricted pages do */ blockedTabIds: Set + /** Tab ids with no content script listening, as a page loaded before the extension has */ + tabsWithoutContentScript: Set + /** Tab ids where injecting does not help, e.g. the tab navigated away mid-flight */ + injectionDoesNotHelp: Set + /** Set to make `chrome.tabs.sendMessage` reject with something other than a missing receiver */ + tabMessageError?: Error chrome: typeof chrome } @@ -107,6 +113,9 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock const tabs: { id?: number; url?: string }[] = [{ id: 1, url: 'https://example.com/' }] const executedScripts: { tabId: number; files: string[] }[] = [] const blockedTabIds = new Set() + const tabsWithoutContentScript = new Set() + const injectionDoesNotHelp = new Set() + const state: { tabMessageError?: Error } = {} function respondTo(message: unknown): unknown { const action = (message as { action?: string } | undefined)?.action @@ -145,6 +154,13 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock tabs: { query: () => Promise.resolve(tabs), sendMessage: (tabId: number, message: unknown) => { + if (state.tabMessageError) { + return Promise.reject(state.tabMessageError) + } + if (tabsWithoutContentScript.has(tabId)) { + // Mirrors Chrome's wording when no content script is listening + return Promise.reject(new Error('Could not establish connection. Receiving end does not exist.')) + } sentTabMessages.push({ tabId, message }) return Promise.resolve() }, @@ -159,6 +175,10 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock return Promise.reject(new Error('Cannot access contents of the page')) } executedScripts.push({ tabId: injection.target.tabId, files: injection.files }) + if (!injectionDoesNotHelp.has(injection.target.tabId)) { + // Injecting gives the tab a listener, so a retry now succeeds + tabsWithoutContentScript.delete(injection.target.tabId) + } return Promise.resolve([]) }, }, @@ -180,6 +200,14 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock tabs, executedScripts, blockedTabIds, + tabsWithoutContentScript, + injectionDoesNotHelp, + get tabMessageError() { + return state.tabMessageError + }, + set tabMessageError(error: Error | undefined) { + state.tabMessageError = error + }, // The mock deliberately implements only the surface this codebase uses chrome: mock as unknown as typeof chrome, }