From e620662fa1c432c6dfb59c269cadf50758852381 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 15:32:41 -0700 Subject: [PATCH 1/9] Text Replacer example --- .../sample.text-replacer/background.js | 29 ++++ .../sample.text-replacer/content.js | 47 +++++++ .../sample.text-replacer/icons/icon128.png | Bin 0 -> 4185 bytes .../sample.text-replacer/icons/icon16.png | Bin 0 -> 490 bytes .../sample.text-replacer/icons/icon19.png | Bin 0 -> 578 bytes .../sample.text-replacer/icons/icon24.png | Bin 0 -> 764 bytes .../sample.text-replacer/icons/icon32.png | Bin 0 -> 1045 bytes .../sample.text-replacer/icons/icon48.png | Bin 0 -> 1648 bytes .../sample.text-replacer/icons/icon64.png | Bin 0 -> 2147 bytes .../sample.text-replacer/manifest.json | 35 +++++ .../sample.text-replacer/popup.css | 91 ++++++++++++ .../sample.text-replacer/popup.html | 80 +++++++++++ .../sample.text-replacer/popup.js | 130 ++++++++++++++++++ 13 files changed, 412 insertions(+) create mode 100644 functional-samples/sample.text-replacer/background.js create mode 100644 functional-samples/sample.text-replacer/content.js create mode 100644 functional-samples/sample.text-replacer/icons/icon128.png create mode 100644 functional-samples/sample.text-replacer/icons/icon16.png create mode 100644 functional-samples/sample.text-replacer/icons/icon19.png create mode 100644 functional-samples/sample.text-replacer/icons/icon24.png create mode 100644 functional-samples/sample.text-replacer/icons/icon32.png create mode 100644 functional-samples/sample.text-replacer/icons/icon48.png create mode 100644 functional-samples/sample.text-replacer/icons/icon64.png create mode 100644 functional-samples/sample.text-replacer/manifest.json create mode 100644 functional-samples/sample.text-replacer/popup.css create mode 100644 functional-samples/sample.text-replacer/popup.html create mode 100644 functional-samples/sample.text-replacer/popup.js diff --git a/functional-samples/sample.text-replacer/background.js b/functional-samples/sample.text-replacer/background.js new file mode 100644 index 0000000000..f85c709fc9 --- /dev/null +++ b/functional-samples/sample.text-replacer/background.js @@ -0,0 +1,29 @@ +chrome.commands.onCommand.addListener((command, tab) => { + if (command == 'replace-text') { + replaceText(tab.id); + } +}); + +chrome.runtime.onInstalled.addListener(async () => { + // Remove any previously registered context menus to avoid conflicts + await chrome.contextMenus.removeAll(); + + // Register the new set of context menus + await chrome.contextMenus.create({ + id: 'replace-text-menuitem', + title: 'Replace text', + }); +}); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId == 'replace-text-menuitem') { + replaceText(tab.id); + } +}); + +function replaceText(tabId) { + chrome.scripting.executeScript({ + target: {tabId}, + files: ['content.js'], + }); +} diff --git a/functional-samples/sample.text-replacer/content.js b/functional-samples/sample.text-replacer/content.js new file mode 100644 index 0000000000..98d92bcdba --- /dev/null +++ b/functional-samples/sample.text-replacer/content.js @@ -0,0 +1,47 @@ +// Copyright 2021 Google LLC +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +// Replace text on the page using a static list of patterns +function textReplacer(replacements) { + const replacementPatterns = buildReplacementRegex(replacements); + replaceText(replacementPatterns); +} + +function buildReplacementRegex(source) { + const output = []; + for (var i = 0; i < source.length; i++) { + if (!source[i]) { continue; } + const [find, replace] = source[i]; + const sanitizedMatch = escapeRegExp(find); + const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi'); + output[i] = [findExp, replace]; + } + return output; +} + +// Use var to avoid "Identifier 'REGEXP_SPECIAL_CHARACTERS' has already been +// declared" errors when running multiple times on the same page. +var REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g; +/** Sanitize user input to prevent unexpected behavior during RegExp execution */ +function escapeRegExp(pattern) { + return pattern.replace(REGEXP_SPECIAL_CHARACTERS, "\\$&") +} + +/** Iterate through all text nodes and replace */ +function replaceText(replacements) { + let node; + const nodeIterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT); + while (node = nodeIterator.nextNode()) { + for (let [find, replace] of replacements) { + node.nodeValue = node.nodeValue.replace(find, replace); + } + } +} + +// Replace text on the page using a list of patterns loaded from storage +chrome.storage.sync.get(['patterns'], function(data) { + textReplacer(data.patterns); +}); diff --git a/functional-samples/sample.text-replacer/icons/icon128.png b/functional-samples/sample.text-replacer/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..4eb8de32db75c967b211faa0018e55233d3b33de GIT binary patch literal 4185 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 MJyYE#9jBQ80dwEK@Bjb+ literal 0 HcmV?d00001 diff --git a/functional-samples/sample.text-replacer/icons/icon16.png b/functional-samples/sample.text-replacer/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..754be60cc62c03fb4d7118860d7b5f6c0c9fe807 GIT binary patch literal 490 zcmVPx$q)9|UR5%f(l1)njQ51&Xv0_Ze(aK!aQbBA}Zi7Nlawi4qqFnfaAfynqXwlDT z(Jx2>SN0oP=uX_URY`H7O+wSqe01kVwGCW9 z9}2;p$$D4`|v{H`mDtoCgQgKPZ>!ZPCDec7{QvGP8JjdmEcqR}fPvXp+R> zmKQMZKZZ>dA;jZ&dwhi2?b58d0JFH3Usx-ZVBgt6pWMHXhiD?iVlY${GU-0LxnV;7 zXt1(^_49K$qfyxR_I}rc4+NkNhmh{>;3*W~I6P!=1`tT0HX7me{vPghnk9T2hN3_x z`=Ap6_3`-I4F)jPRaz~4^m=e565x2AC7FhOcNgx{QyAoXby;RH3Rv=b;fzGUkug@o zVVK>7qa&7N8k2gRE|Jo1Lp(laF$xgLg2;^qT_AIb#<#Z+|BT_iyoA5g0Ur!Ps#Y0b g;csLbFyx%ZC)T%#XH3bx{{R3007*qoM6N<$f}o1u7ytkO literal 0 HcmV?d00001 diff --git a/functional-samples/sample.text-replacer/icons/icon19.png b/functional-samples/sample.text-replacer/icons/icon19.png new file mode 100644 index 0000000000000000000000000000000000000000..7452a65854e1d1d6cb9252bd7c3039c0cd3a8f05 GIT binary patch literal 578 zcmV-I0=@l-P)Px${7FPXR5%f}lfO$sQ546&@AcFqpG%;aB%#(&EkW5{Lxn>&M2l;yLnQqVEe__Q zO*Ix#G>EQ2PC>LpG)QWc4HnNz%^$COFFfn^tU>I+PffuzIf;6qfZEj+>UVd)h6o*xDT)}}+{D2AJl@aFpw?;(U>YBX(c?jJ zYYUZ3MhDepCK!!y?(ai6JVb6`K^I_fyD_x8i()v8>cs`5;bAoLd8X54X5Ze%`}sM_ z$HzL5wjep3a3zxrQa(Asb3D#m+yfX!MnHG|6GSp`M5B=GcJxnAqfDTh=`s_oR|q{5p|(Z`YkRsF@W*tyFjg$W9*O8ttfUN1}&6Htzh@Rm-)O2D0rmW1c}8fvqN*Hns=v@?@wFcCOnF_>p&U?Bx7n9ZD+ zP6}&l$ohO}K0RS@V}tX5!2$u4Pfwv79H6wb1E?wka%wW69U{>CBo89lY@D056K!OA z%==&v^=y_2%(JtQ=>|VKfwmeN4OA~LQ6k0r-Ttr6zVFh_xP52c1pkcr1l`u6`!#z> Q8~^|S07*qoM6N<$g2|=>=>Px# literal 0 HcmV?d00001 diff --git a/functional-samples/sample.text-replacer/icons/icon24.png b/functional-samples/sample.text-replacer/icons/icon24.png new file mode 100644 index 0000000000000000000000000000000000000000..da0aab2e79c9c1d45ee993cde7520c21a782ad49 GIT binary patch literal 764 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 Z^nYUYs(Z(hQXv2U002ovPDHLkV1mk_{qX<* literal 0 HcmV?d00001 diff --git a/functional-samples/sample.text-replacer/manifest.json b/functional-samples/sample.text-replacer/manifest.json new file mode 100644 index 0000000000..90468c2149 --- /dev/null +++ b/functional-samples/sample.text-replacer/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Text Replacer", + "version": "1.0.0", + "manifest_version": 3, + "description": "Replace any a line of text on a page with another line of text.", + "icons": { + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "64": "icons/icon64.png" + }, + "action": { + "default_title": "Show an alert", + "default_icon": { + "16": "icons/icon16.png", + "24": "icons/icon24.png", + "32": "icons/icon32.png" + }, + "default_popup": "popup.html" + }, + "permissions": [ + "scripting", + "activeTab", + "storage", + "commands", + "contextMenus" + ], + "background": { + "service_worker": "background.js" + }, + "commands": { + "replace-text": { + "description": "Replace text on the current page." + } + } +} diff --git a/functional-samples/sample.text-replacer/popup.css b/functional-samples/sample.text-replacer/popup.css new file mode 100644 index 0000000000..fc57133313 --- /dev/null +++ b/functional-samples/sample.text-replacer/popup.css @@ -0,0 +1,91 @@ +/* +Copyright 2021 Google LLC + +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file or at +https://developers.google.com/open-source/licenses/bsd +*/ + +html, body { + height: 200px; + width: 400px; + margin: 0; + padding: 0; + font-size: 16px; +} + +body { + padding-bottom: 2em; +} + +.form--table { + width: 100%; + border-collapse: collapse; +} + +.form--input { + width: 100%; + padding: .25rem .5rem; + background: #eee; + box-sizing: border-box; + border: 1px solid #0004; + line-height: 1.5; +} + +th { + text-transform: uppercase; + font-size: .7rem; + color: hsl(0, 0%, 30%); +} + +td { + padding: .25rem .1rem; +} + +td:first-child { + padding-left: .25rem; +} +td:last-child { + padding-right: .25rem; +} + +.form--controls { + position: fixed; + display: flex; + bottom: 0; + right: 0; + left: 0; +} +.form--controls > * { + flex: 1; +} + +.form--controls > * { + border: none; + height: 2rem; + margin: .1rem; + cursor: pointer; + border: 1px solid hsla(0, 0%, 0%, .2); +} + +.form--controls > *:first-child { + margin-left: .25rem; +} +.form--controls > *:last-child { + margin-right: .25rem; +} + +#clear { + background: hsl(0, 0%, 100%); +} +#clear:hover, +#clear:focus { + background: hsl(0, 0%, 80%); +} +#submit { + background: hsl(190, 100%, 60%); +} +#submit:hover, +#submit:focus { + background: hsl(190, 80%, 50%); +} diff --git a/functional-samples/sample.text-replacer/popup.html b/functional-samples/sample.text-replacer/popup.html new file mode 100644 index 0000000000..1c4820501d --- /dev/null +++ b/functional-samples/sample.text-replacer/popup.html @@ -0,0 +1,80 @@ + + + + + + + + Document + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FindReplace
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ +
+ + +
+
+ + + + + diff --git a/functional-samples/sample.text-replacer/popup.js b/functional-samples/sample.text-replacer/popup.js new file mode 100644 index 0000000000..7adb45ad02 --- /dev/null +++ b/functional-samples/sample.text-replacer/popup.js @@ -0,0 +1,130 @@ +// Copyright 2021 Google LLC +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +const form = document.querySelector('form'); + +load(['patterns']) + .then(data => data.patterns) + .then(loadFormData); + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + await saveFormData(); + + let currentTab = await getCurrentTab(); + chrome.scripting.executeScript({ + target: {tabId: currentTab.id}, + files: ['content.js'] + }); +}); + +document.getElementById('clear').addEventListener('click', (event) => { + form.querySelectorAll('input[type=text]').forEach(el => el.value = ''); + saveFormData(); +}); + +form.addEventListener('input', debounce(saveFormData, 250)); + +/** + * Populate the form with values from persistent storage. + */ +function loadFormData(patterns) { + const inputs = form.querySelectorAll('input[type=text]'); + const flatPatterns = patterns.flat(); + + inputs.forEach((input, index) => { + input.value = flatPatterns[index] || ''; + }); +} + +/** + * Write the form values to persistent storage. + */ +function saveFormData() { + const inputs = form.querySelectorAll('input[type=text]'); + + const patterns = [...inputs].reduce((acc, input, index) => { + const outerIndex = index >> 1; + const innerIndex = index % 2; + + if (innerIndex === 0) { + // Set the "find" value + acc[outerIndex] = [input.value, '']; + } else { + // Set the "replace" value + acc[outerIndex][innerIndex] = input.value; + } + + return acc; + }, []); + + return save({patterns}); +} + +/** + * Limits how often the supplied callback function will be called. + * + * @see https://developers.google.com/web/fundamentals/performance/rendering/debounce-your-input-handlers + * + * @param {function} fn Callback function that you want to debounce. + * @param {number} wait The amount of time to wait before calling the function. + */ +function debounce(fn, wait = 100) { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + fn.apply(this, args); + }, wait); + } +} + +/** + * Fetch the currently active tab. + * + * @returns chrome.tabs.Tab instance + */ +async function getCurrentTab() { + let queryOptions = { active: true, currentWindow: true }; + let [tab] = await chrome.tabs.query(queryOptions); + return tab; +} + + +/** + * Minimal promise wrapper for chrome.storage.sync.set(). + * + * @param {object} data Object containing key-value pairs of data to persist. + */ +async function save(data) { + return new Promise((resolve, reject) => { + chrome.storage.sync.set(data, (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(result); + } + }); + }); +} + +/** + * Minimal promise wrapper for chrome.storage.sync.get(). + * + * @param {string[]} keys Array of keys to retrieve from storage. + */ +function load(keys) { + return new Promise((resolve, reject) => { + chrome.storage.sync.get(keys, (data) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(data); + } + }); + }); +} From e10e8d2f4c91b4bfa5f6392a51eab54c8c667fe9 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 15:33:09 -0700 Subject: [PATCH 2/9] Addressing feedback from PR #612 --- .../sample.text-replacer/background.js | 38 +++++++-- .../sample.text-replacer/content.js | 80 ++++++++++--------- .../sample.text-replacer/manifest.json | 10 ++- .../sample.text-replacer/popup.html | 4 +- .../sample.text-replacer/popup.js | 60 ++++---------- 5 files changed, 100 insertions(+), 92 deletions(-) diff --git a/functional-samples/sample.text-replacer/background.js b/functional-samples/sample.text-replacer/background.js index f85c709fc9..3bca14a047 100644 --- a/functional-samples/sample.text-replacer/background.js +++ b/functional-samples/sample.text-replacer/background.js @@ -1,8 +1,7 @@ -chrome.commands.onCommand.addListener((command, tab) => { - if (command == 'replace-text') { - replaceText(tab.id); - } -}); +const REPLACE_TEXT_MENUITEM_ID = 'replace-text-menuitem'; +const REPLACE_TEXT_COMMAND_ID = 'replace-text-command'; +const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; +const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements'; chrome.runtime.onInstalled.addListener(async () => { // Remove any previously registered context menus to avoid conflicts @@ -10,14 +9,39 @@ chrome.runtime.onInstalled.addListener(async () => { // Register the new set of context menus await chrome.contextMenus.create({ - id: 'replace-text-menuitem', + id: REPLACE_TEXT_MENUITEM_ID, title: 'Replace text', }); }); +chrome.commands.onCommand.addListener((command, tab) => { + if (command == REPLACE_TEXT_COMMAND_ID) { + replaceText(tab.id); + } else { + throw new Error(`Unknown command executed with ID "${command}"`); + } +}); + chrome.contextMenus.onClicked.addListener((info, tab) => { - if (info.menuItemId == 'replace-text-menuitem') { + if (info.menuItemId == REPLACE_TEXT_MENUITEM_ID) { replaceText(tab.id); + } else { + throw new Error(`Unknown context menu option clicked with ID "${info.menuItemId}"`); + } +}); + +// Route all storage reads/writes through the background so we have a single +// source of truth. +chrome.runtime.onMessage.addListener((message, ) => { + switch (message.id) { + case GET_REPLACEMENTS_MESSAGE_ID: + return chrome.storage.sync.get(['patterns']); + + case SET_REPLACEMENTS_MESSAGE_ID: + return chrome.storage.sync.set({'patterns': message.data}); + + default: + throw new Error(`Unknown message received with ID "${message.id}"`); } }); diff --git a/functional-samples/sample.text-replacer/content.js b/functional-samples/sample.text-replacer/content.js index 98d92bcdba..fd8fa59970 100644 --- a/functional-samples/sample.text-replacer/content.js +++ b/functional-samples/sample.text-replacer/content.js @@ -4,44 +4,50 @@ // license that can be found in the LICENSE file or at // https://developers.google.com/open-source/licenses/bsd -// Replace text on the page using a static list of patterns -function textReplacer(replacements) { - const replacementPatterns = buildReplacementRegex(replacements); - replaceText(replacementPatterns); -} - -function buildReplacementRegex(source) { - const output = []; - for (var i = 0; i < source.length; i++) { - if (!source[i]) { continue; } - const [find, replace] = source[i]; - const sanitizedMatch = escapeRegExp(find); - const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi'); - output[i] = [findExp, replace]; +// Wrap the in an immediately invoked function expression (IIFE) in order to +// prevent local variables from polluting global scope +(function () { + const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; + + + function buildReplacementRegex(source) { + const output = []; + for (let i = 0; i < source.length; i++) { + if (!source[i]) { continue; } + const [find, replace] = source[i]; + const sanitizedMatch = escapeRegExp(find); + const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi'); + output[i] = [findExp, replace]; + } + return output; + } + + // This may not cover all special characters used in regular repressions. For + // example purposes only. + var REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g; + /** Sanitize user input to prevent unexpected behavior during RegExp execution */ + function escapeRegExp(pattern) { + return pattern.replace(REGEXP_SPECIAL_CHARACTERS, "\\$&") } - return output; -} - -// Use var to avoid "Identifier 'REGEXP_SPECIAL_CHARACTERS' has already been -// declared" errors when running multiple times on the same page. -var REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g; -/** Sanitize user input to prevent unexpected behavior during RegExp execution */ -function escapeRegExp(pattern) { - return pattern.replace(REGEXP_SPECIAL_CHARACTERS, "\\$&") -} - -/** Iterate through all text nodes and replace */ -function replaceText(replacements) { - let node; - const nodeIterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT); - while (node = nodeIterator.nextNode()) { - for (let [find, replace] of replacements) { - node.nodeValue = node.nodeValue.replace(find, replace); + + /** Iterate through all text nodes and replace */ + function replaceText(replacements) { + const nodeIterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT); + + let node; + while (node = nodeIterator.nextNode()) { + for (let [find, replace] of replacements) { + node.nodeValue = node.nodeValue.replace(find, replace); + } } } -} -// Replace text on the page using a list of patterns loaded from storage -chrome.storage.sync.get(['patterns'], function(data) { - textReplacer(data.patterns); -}); + // Get the patterns to replace from storage, then build a regex from them and + // replace all text on the page. + chrome.runtime.sendMessage({ id: GET_REPLACEMENTS_MESSAGE_ID }) + .then(function(data) { + const replacementPatterns = buildReplacementRegex(data.patterns); + replaceText(replacementPatterns); + }); + +})() diff --git a/functional-samples/sample.text-replacer/manifest.json b/functional-samples/sample.text-replacer/manifest.json index 90468c2149..dfcc90ac28 100644 --- a/functional-samples/sample.text-replacer/manifest.json +++ b/functional-samples/sample.text-replacer/manifest.json @@ -9,7 +9,7 @@ "64": "icons/icon64.png" }, "action": { - "default_title": "Show an alert", + "default_title": "Manage text replacements", "default_icon": { "16": "icons/icon16.png", "24": "icons/icon24.png", @@ -28,8 +28,12 @@ "service_worker": "background.js" }, "commands": { - "replace-text": { - "description": "Replace text on the current page." + "replace-text-command": { + "description": "Replace text on the current page.", + "suggested_key": { + "default": "Alt+R", + "mac": "MacCtrl+R" + } } } } diff --git a/functional-samples/sample.text-replacer/popup.html b/functional-samples/sample.text-replacer/popup.html index 1c4820501d..ac95d74d76 100644 --- a/functional-samples/sample.text-replacer/popup.html +++ b/functional-samples/sample.text-replacer/popup.html @@ -69,8 +69,8 @@
- - + +
diff --git a/functional-samples/sample.text-replacer/popup.js b/functional-samples/sample.text-replacer/popup.js index 7adb45ad02..c59dac8c19 100644 --- a/functional-samples/sample.text-replacer/popup.js +++ b/functional-samples/sample.text-replacer/popup.js @@ -4,11 +4,13 @@ // license that can be found in the LICENSE file or at // https://developers.google.com/open-source/licenses/bsd +const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; +const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements'; const form = document.querySelector('form'); -load(['patterns']) - .then(data => data.patterns) - .then(loadFormData); +// Ask the background for replacement patterns and initialize the page +chrome.runtime.sendMessage({id: GET_REPLACEMENTS_MESSAGE_ID}) + .then(data => loadFormData(data.patterns)); form.addEventListener('submit', async (event) => { event.preventDefault(); @@ -21,9 +23,12 @@ form.addEventListener('submit', async (event) => { }); }); -document.getElementById('clear').addEventListener('click', (event) => { - form.querySelectorAll('input[type=text]').forEach(el => el.value = ''); - saveFormData(); +document.getElementById('reset').addEventListener('click', (event) => { + // automatically reset the form's contents, but those + // changes won't settle until the the next turn of the event loop. Since + // saveFormData works directly gainst DOM, we have to call it after a minimal + // delay. + setTimeout(saveFormData, 0); }); form.addEventListener('input', debounce(saveFormData, 250)); @@ -47,7 +52,7 @@ function saveFormData() { const inputs = form.querySelectorAll('input[type=text]'); const patterns = [...inputs].reduce((acc, input, index) => { - const outerIndex = index >> 1; + const outerIndex = Math.floor(index / 2); const innerIndex = index % 2; if (innerIndex === 0) { @@ -61,7 +66,11 @@ function saveFormData() { return acc; }, []); - return save({patterns}); + // Ask the background to persist this data + chrome.runtime.sendMessage({ + id: SET_REPLACEMENTS_MESSAGE_ID, + data: patterns, + }); } /** @@ -93,38 +102,3 @@ async function getCurrentTab() { let [tab] = await chrome.tabs.query(queryOptions); return tab; } - - -/** - * Minimal promise wrapper for chrome.storage.sync.set(). - * - * @param {object} data Object containing key-value pairs of data to persist. - */ -async function save(data) { - return new Promise((resolve, reject) => { - chrome.storage.sync.set(data, (result) => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); - } else { - resolve(result); - } - }); - }); -} - -/** - * Minimal promise wrapper for chrome.storage.sync.get(). - * - * @param {string[]} keys Array of keys to retrieve from storage. - */ -function load(keys) { - return new Promise((resolve, reject) => { - chrome.storage.sync.get(keys, (data) => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); - } else { - resolve(data); - } - }); - }); -} From 000bb90f0c441799d6316e1fc400df6f84d96147 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 15:51:45 -0700 Subject: [PATCH 3/9] Fix issues identified in #1718 review --- functional-samples/sample.text-replacer/background.js | 3 ++- functional-samples/sample.text-replacer/manifest.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/functional-samples/sample.text-replacer/background.js b/functional-samples/sample.text-replacer/background.js index 3bca14a047..f4384b0967 100644 --- a/functional-samples/sample.text-replacer/background.js +++ b/functional-samples/sample.text-replacer/background.js @@ -35,7 +35,8 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { chrome.runtime.onMessage.addListener((message, ) => { switch (message.id) { case GET_REPLACEMENTS_MESSAGE_ID: - return chrome.storage.sync.get(['patterns']); + // Fall back to an empty array 'patterns' is not set + return chrome.storage.sync.get({patterns: []}); case SET_REPLACEMENTS_MESSAGE_ID: return chrome.storage.sync.set({'patterns': message.data}); diff --git a/functional-samples/sample.text-replacer/manifest.json b/functional-samples/sample.text-replacer/manifest.json index dfcc90ac28..747701f48f 100644 --- a/functional-samples/sample.text-replacer/manifest.json +++ b/functional-samples/sample.text-replacer/manifest.json @@ -2,7 +2,7 @@ "name": "Text Replacer", "version": "1.0.0", "manifest_version": 3, - "description": "Replace any a line of text on a page with another line of text.", + "description": "Replace any line of text on a page with another line of text.", "icons": { "32": "icons/icon32.png", "48": "icons/icon48.png", From 0280b844ec5b03f5ddf19cfc96b5c70c5730ac2d Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 15:58:08 -0700 Subject: [PATCH 4/9] Add more icon options to the manifest --- .../sample.text-replacer/icons/icon19.png | Bin 578 -> 0 bytes .../sample.text-replacer/manifest.json | 11 ++++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) delete mode 100644 functional-samples/sample.text-replacer/icons/icon19.png diff --git a/functional-samples/sample.text-replacer/icons/icon19.png b/functional-samples/sample.text-replacer/icons/icon19.png deleted file mode 100644 index 7452a65854e1d1d6cb9252bd7c3039c0cd3a8f05..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 578 zcmV-I0=@l-P)Px${7FPXR5%f}lfO$sQ546&@AcFqpG%;aB%#(&EkW5{Lxn>&M2l;yLnQqVEe__Q zO*Ix#G>EQ2PC>LpG)QWc4HnNz%^$COFFfn^tU>I+PffuzIf;6qfZEj+>UVd)h6o*xDT)}}+{D2AJl@aFpw?;(U>YBX(c?jJ zYYUZ3MhDepCK!!y?(ai6JVb6`K^I_fyD_x8i()v8>cs`5;bAoLd8X54X5Ze%`}sM_ z$HzL5wjep3a3zxrQa(Asb3D#m+yfX!MnHG|6GSp`M5B=GcJxnAqfDTh=`s_oR|q{5p|(Z`YkRsF@W*tyFjg$W9*O8ttfUN1}&6Htzh@Rm-)O2D0rmW1c}8fvqN*Hns=v@?@wFcCOnF_>p&U?Bx7n9ZD+ zP6}&l$ohO}K0RS@V}tX5!2$u4Pfwv79H6wb1E?wka%wW69U{>CBo89lY@D056K!OA z%==&v^=y_2%(JtQ=>|VKfwmeN4OA~LQ6k0r-Ttr6zVFh_xP52c1pkcr1l`u6`!#z> Q8~^|S07*qoM6N<$g2|=>=>Px# diff --git a/functional-samples/sample.text-replacer/manifest.json b/functional-samples/sample.text-replacer/manifest.json index 747701f48f..305cccc080 100644 --- a/functional-samples/sample.text-replacer/manifest.json +++ b/functional-samples/sample.text-replacer/manifest.json @@ -4,17 +4,22 @@ "manifest_version": 3, "description": "Replace any line of text on a page with another line of text.", "icons": { + "16": "icons/icon16.png", + "24": "icons/icon24.png", "32": "icons/icon32.png", "48": "icons/icon48.png", - "64": "icons/icon64.png" + "64": "icons/icon64.png", + "128": "icons/icon128.png" }, "action": { "default_title": "Manage text replacements", "default_icon": { "16": "icons/icon16.png", "24": "icons/icon24.png", - "32": "icons/icon32.png" - }, + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "64": "icons/icon64.png" + }, "default_popup": "popup.html" }, "permissions": [ From 667fb2e79956389ab93f60093552e838866f9626 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 16:02:44 -0700 Subject: [PATCH 5/9] Add README --- functional-samples/sample.text-replacer/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 functional-samples/sample.text-replacer/README.md diff --git a/functional-samples/sample.text-replacer/README.md b/functional-samples/sample.text-replacer/README.md new file mode 100644 index 0000000000..a0b81ad211 --- /dev/null +++ b/functional-samples/sample.text-replacer/README.md @@ -0,0 +1,14 @@ +# Text Replacer + +This sample demonstrates how an extension can replace text on a page in response to user invocation. + +## Overview + +Users can use the extension's popup to define up to 5 pairs of text replacements. Text is replaced when the user clicks the "Replace" button in the popup, when they select the "Replace text" context menu option on a page, or when they use the extension's keyboard shortcut (command). + +## Running this extension + +1. Clone this repository. +2. Load this directory in Chrome as an [unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked). +3. Navigate to any page (make sure that the URL doesn't start with `chrome://`). +4. Click the extension icon. From 99b716389ae35d5de253c10dc216eba958e754b3 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 19:15:46 -0700 Subject: [PATCH 6/9] Update copyright and license headers --- .../sample.text-replacer/background.js | 14 ++++++++++++++ .../sample.text-replacer/content.js | 17 ++++++++++++----- .../sample.text-replacer/popup.css | 16 ++++++++++++---- .../sample.text-replacer/popup.html | 16 ++++++++++++---- .../sample.text-replacer/popup.js | 16 ++++++++++++---- 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/functional-samples/sample.text-replacer/background.js b/functional-samples/sample.text-replacer/background.js index f4384b0967..2aafd63028 100644 --- a/functional-samples/sample.text-replacer/background.js +++ b/functional-samples/sample.text-replacer/background.js @@ -1,3 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + const REPLACE_TEXT_MENUITEM_ID = 'replace-text-menuitem'; const REPLACE_TEXT_COMMAND_ID = 'replace-text-command'; const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; diff --git a/functional-samples/sample.text-replacer/content.js b/functional-samples/sample.text-replacer/content.js index fd8fa59970..5eba93adb9 100644 --- a/functional-samples/sample.text-replacer/content.js +++ b/functional-samples/sample.text-replacer/content.js @@ -1,15 +1,22 @@ -// Copyright 2021 Google LLC +// Copyright 2026 Google LLC // -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file or at -// https://developers.google.com/open-source/licenses/bsd +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. // Wrap the in an immediately invoked function expression (IIFE) in order to // prevent local variables from polluting global scope (function () { const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; - function buildReplacementRegex(source) { const output = []; for (let i = 0; i < source.length; i++) { diff --git a/functional-samples/sample.text-replacer/popup.css b/functional-samples/sample.text-replacer/popup.css index fc57133313..b24f30000d 100644 --- a/functional-samples/sample.text-replacer/popup.css +++ b/functional-samples/sample.text-replacer/popup.css @@ -1,9 +1,17 @@ /* -Copyright 2021 Google LLC +Copyright 2026 Google LLC -Use of this source code is governed by a BSD-style -license that can be found in the LICENSE file or at -https://developers.google.com/open-source/licenses/bsd +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. */ html, body { diff --git a/functional-samples/sample.text-replacer/popup.html b/functional-samples/sample.text-replacer/popup.html index ac95d74d76..0a68a7e59e 100644 --- a/functional-samples/sample.text-replacer/popup.html +++ b/functional-samples/sample.text-replacer/popup.html @@ -1,10 +1,18 @@ diff --git a/functional-samples/sample.text-replacer/popup.js b/functional-samples/sample.text-replacer/popup.js index c59dac8c19..8dc1700dc5 100644 --- a/functional-samples/sample.text-replacer/popup.js +++ b/functional-samples/sample.text-replacer/popup.js @@ -1,8 +1,16 @@ -// Copyright 2021 Google LLC +// Copyright 2026 Google LLC // -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file or at -// https://developers.google.com/open-source/licenses/bsd +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements'; From 0f39a16d467f41ae098d6da4d230d0de20ee9c85 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 20:59:46 -0700 Subject: [PATCH 7/9] Iterating on feedback in #1718 --- functional-samples/sample.text-replacer/README.md | 4 ++++ functional-samples/sample.text-replacer/background.js | 6 +++--- functional-samples/sample.text-replacer/content.js | 8 ++++++-- functional-samples/sample.text-replacer/manifest.json | 1 + functional-samples/sample.text-replacer/popup.js | 1 + 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/functional-samples/sample.text-replacer/README.md b/functional-samples/sample.text-replacer/README.md index a0b81ad211..b116d6455e 100644 --- a/functional-samples/sample.text-replacer/README.md +++ b/functional-samples/sample.text-replacer/README.md @@ -6,6 +6,10 @@ This sample demonstrates how an extension can replace text on a page in response Users can use the extension's popup to define up to 5 pairs of text replacements. Text is replaced when the user clicks the "Replace" button in the popup, when they select the "Replace text" context menu option on a page, or when they use the extension's keyboard shortcut (command). +## Compatibility + +`"minimum_chrome_version"` is set to 148 because that version adds support for returning a promise from an `onMessage` event handler ([docs](https://developer.chrome.com/docs/extensions/develop/concepts/messaging#:~:text=call%20it%20later.-,Return%20a%20promise,-From%20Chrome%20148)) to respond to a message asynchronously. + ## Running this extension 1. Clone this repository. diff --git a/functional-samples/sample.text-replacer/background.js b/functional-samples/sample.text-replacer/background.js index 2aafd63028..09bdbdd843 100644 --- a/functional-samples/sample.text-replacer/background.js +++ b/functional-samples/sample.text-replacer/background.js @@ -45,15 +45,15 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { }); // Route all storage reads/writes through the background so we have a single -// source of truth. -chrome.runtime.onMessage.addListener((message, ) => { +// source of truth +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { switch (message.id) { case GET_REPLACEMENTS_MESSAGE_ID: // Fall back to an empty array 'patterns' is not set return chrome.storage.sync.get({patterns: []}); case SET_REPLACEMENTS_MESSAGE_ID: - return chrome.storage.sync.set({'patterns': message.data}); + return chrome.storage.sync.set({patterns: message.data}); default: throw new Error(`Unknown message received with ID "${message.id}"`); diff --git a/functional-samples/sample.text-replacer/content.js b/functional-samples/sample.text-replacer/content.js index 5eba93adb9..f44db88a9d 100644 --- a/functional-samples/sample.text-replacer/content.js +++ b/functional-samples/sample.text-replacer/content.js @@ -18,6 +18,8 @@ const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; function buildReplacementRegex(source) { + // Guard against nullish inputs + source ??= []; const output = []; for (let i = 0; i < source.length; i++) { if (!source[i]) { continue; } @@ -29,8 +31,8 @@ return output; } - // This may not cover all special characters used in regular repressions. For - // example purposes only. + // For example purposes only. This may not cover all special characters used + // in regular repressions. var REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g; /** Sanitize user input to prevent unexpected behavior during RegExp execution */ function escapeRegExp(pattern) { @@ -44,6 +46,8 @@ let node; while (node = nodeIterator.nextNode()) { for (let [find, replace] of replacements) { + // Guard against nullish replacement values + replace ??= ''; node.nodeValue = node.nodeValue.replace(find, replace); } } diff --git a/functional-samples/sample.text-replacer/manifest.json b/functional-samples/sample.text-replacer/manifest.json index 305cccc080..49c11f1cc8 100644 --- a/functional-samples/sample.text-replacer/manifest.json +++ b/functional-samples/sample.text-replacer/manifest.json @@ -3,6 +3,7 @@ "version": "1.0.0", "manifest_version": 3, "description": "Replace any line of text on a page with another line of text.", + "minimum_chrome_version": "148", "icons": { "16": "icons/icon16.png", "24": "icons/icon24.png", diff --git a/functional-samples/sample.text-replacer/popup.js b/functional-samples/sample.text-replacer/popup.js index 8dc1700dc5..ca16038bc6 100644 --- a/functional-samples/sample.text-replacer/popup.js +++ b/functional-samples/sample.text-replacer/popup.js @@ -14,6 +14,7 @@ const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements'; + const form = document.querySelector('form'); // Ask the background for replacement patterns and initialize the page From 12def689d2edcd193f6e33150fae377285489d65 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 22:57:42 -0700 Subject: [PATCH 8/9] Simplify regex creation logic --- .../sample.text-replacer/content.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/functional-samples/sample.text-replacer/content.js b/functional-samples/sample.text-replacer/content.js index f44db88a9d..6c493dfad6 100644 --- a/functional-samples/sample.text-replacer/content.js +++ b/functional-samples/sample.text-replacer/content.js @@ -17,17 +17,14 @@ (function () { const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; - function buildReplacementRegex(source) { - // Guard against nullish inputs - source ??= []; - const output = []; - for (let i = 0; i < source.length; i++) { - if (!source[i]) { continue; } - const [find, replace] = source[i]; - const sanitizedMatch = escapeRegExp(find); - const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi'); - output[i] = [findExp, replace]; - } + function buildReplacementRegex(source = []) { + const output = source + .filter(([find] = []) => find) + .map(([find, replace]) => { + const sanitizedMatch = escapeRegExp(find); + const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi'); + return [findExp, replace]; + }); return output; } From 1f1c7c6f3e555de163f4669e221baa3069048c95 Mon Sep 17 00:00:00 2001 From: Simeon Vincent Date: Sun, 19 Jul 2026 23:13:19 -0700 Subject: [PATCH 9/9] Fix ESLint errors and warnings --- .../sample.text-replacer/background.js | 16 ++++++++------- .../sample.text-replacer/content.js | 20 +++++++++++-------- .../sample.text-replacer/popup.js | 13 ++++++------ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/functional-samples/sample.text-replacer/background.js b/functional-samples/sample.text-replacer/background.js index 09bdbdd843..a7454d05e7 100644 --- a/functional-samples/sample.text-replacer/background.js +++ b/functional-samples/sample.text-replacer/background.js @@ -24,7 +24,7 @@ chrome.runtime.onInstalled.addListener(async () => { // Register the new set of context menus await chrome.contextMenus.create({ id: REPLACE_TEXT_MENUITEM_ID, - title: 'Replace text', + title: 'Replace text' }); }); @@ -40,20 +40,22 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId == REPLACE_TEXT_MENUITEM_ID) { replaceText(tab.id); } else { - throw new Error(`Unknown context menu option clicked with ID "${info.menuItemId}"`); + throw new Error( + `Unknown context menu option clicked with ID "${info.menuItemId}"` + ); } }); // Route all storage reads/writes through the background so we have a single // source of truth -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((message) => { switch (message.id) { case GET_REPLACEMENTS_MESSAGE_ID: // Fall back to an empty array 'patterns' is not set - return chrome.storage.sync.get({patterns: []}); + return chrome.storage.sync.get({ patterns: [] }); case SET_REPLACEMENTS_MESSAGE_ID: - return chrome.storage.sync.set({patterns: message.data}); + return chrome.storage.sync.set({ patterns: message.data }); default: throw new Error(`Unknown message received with ID "${message.id}"`); @@ -62,7 +64,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { function replaceText(tabId) { chrome.scripting.executeScript({ - target: {tabId}, - files: ['content.js'], + target: { tabId }, + files: ['content.js'] }); } diff --git a/functional-samples/sample.text-replacer/content.js b/functional-samples/sample.text-replacer/content.js index 6c493dfad6..4d25a59b25 100644 --- a/functional-samples/sample.text-replacer/content.js +++ b/functional-samples/sample.text-replacer/content.js @@ -30,18 +30,22 @@ // For example purposes only. This may not cover all special characters used // in regular repressions. - var REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g; + const REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g; + /** Sanitize user input to prevent unexpected behavior during RegExp execution */ function escapeRegExp(pattern) { - return pattern.replace(REGEXP_SPECIAL_CHARACTERS, "\\$&") + return pattern.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&'); } /** Iterate through all text nodes and replace */ function replaceText(replacements) { - const nodeIterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT); + const nodeIterator = document.createNodeIterator( + document.body, + NodeFilter.SHOW_TEXT + ); let node; - while (node = nodeIterator.nextNode()) { + while ((node = nodeIterator.nextNode())) { for (let [find, replace] of replacements) { // Guard against nullish replacement values replace ??= ''; @@ -52,10 +56,10 @@ // Get the patterns to replace from storage, then build a regex from them and // replace all text on the page. - chrome.runtime.sendMessage({ id: GET_REPLACEMENTS_MESSAGE_ID }) - .then(function(data) { + chrome.runtime + .sendMessage({ id: GET_REPLACEMENTS_MESSAGE_ID }) + .then((data) => { const replacementPatterns = buildReplacementRegex(data.patterns); replaceText(replacementPatterns); }); - -})() +})(); diff --git a/functional-samples/sample.text-replacer/popup.js b/functional-samples/sample.text-replacer/popup.js index ca16038bc6..286e6e05a7 100644 --- a/functional-samples/sample.text-replacer/popup.js +++ b/functional-samples/sample.text-replacer/popup.js @@ -18,8 +18,9 @@ const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements'; const form = document.querySelector('form'); // Ask the background for replacement patterns and initialize the page -chrome.runtime.sendMessage({id: GET_REPLACEMENTS_MESSAGE_ID}) - .then(data => loadFormData(data.patterns)); +chrome.runtime + .sendMessage({ id: GET_REPLACEMENTS_MESSAGE_ID }) + .then((data) => loadFormData(data.patterns)); form.addEventListener('submit', async (event) => { event.preventDefault(); @@ -27,12 +28,12 @@ form.addEventListener('submit', async (event) => { let currentTab = await getCurrentTab(); chrome.scripting.executeScript({ - target: {tabId: currentTab.id}, + target: { tabId: currentTab.id }, files: ['content.js'] }); }); -document.getElementById('reset').addEventListener('click', (event) => { +document.getElementById('reset').addEventListener('click', (_event) => { // automatically reset the form's contents, but those // changes won't settle until the the next turn of the event loop. Since // saveFormData works directly gainst DOM, we have to call it after a minimal @@ -78,7 +79,7 @@ function saveFormData() { // Ask the background to persist this data chrome.runtime.sendMessage({ id: SET_REPLACEMENTS_MESSAGE_ID, - data: patterns, + data: patterns }); } @@ -98,7 +99,7 @@ function debounce(fn, wait = 100) { timeout = null; fn.apply(this, args); }, wait); - } + }; } /**