fix: an open popup follows a network switch the background saves (closes #650)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

When the background saves a network switch after the user approves a
site's request, or another popup page saves one, an open popup takes the
new network from the stored record inside a save, holding the lock, and
checks the record once at start-up for a switch saved before it
listened. It then leaves the page as a switch in Settings does: the cut
and the redraw are two Settings functions both paths call, and both
reset the popup's caches through one function. A switch in Settings is
now made inside its own save too, so one picked while a follow waits for
the lock is made after it. The popup never switches the network itself.

Model: opus-5-5
This commit is contained in:
2026-10-20 23:57:42 +00:00
parent 45aead6340
commit eb41b90ee0
16 changed files with 555 additions and 91 deletions
+44 -6
View File
@@ -11,6 +11,8 @@ const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { copyChainSwitchFields } = require("../shared/chainSwitchFields");
const { clearPopupCaches } = require("../shared/chainSwitch");
const {
$,
showView,
@@ -22,6 +24,7 @@ const {
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
onStorageChanged,
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
@@ -72,12 +75,13 @@ let refreshTimer = null;
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// changes only the stored record. A switch while the refresh ran cleared every
// address's balances (applyChainSwitchFields() in
// src/shared/chainSwitchFields.js), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped
// refresh is not recorded as done. Results are copied by address, so an address
// added or removed meanwhile is left as it is.
// reaches this page only after the stored record (followNetworkSwitch() below).
// A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
// recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
@@ -147,6 +151,7 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
restoreView,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
@@ -214,6 +219,28 @@ function fallbackView() {
showView("main");
}
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
async function followNetworkSwitch() {
let switched = false;
// A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => {
if (stored.networkId === state.networkId) return false;
settings.cutViewStack();
copyChainSwitchFields(state, stored);
clearPopupCaches();
switched = true;
}).catch(() => {});
if (switched) settings.showNewNetwork();
}
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
@@ -358,6 +385,17 @@ async function init() {
settingsAddToken.init(ctx);
deleteAddress.init(ctx);
// Only a record naming a network other than this page's is followed: the
// one this page's own switch in Settings saves names the network it
// already has, and a record naming none is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (written.networkId !== state.networkId) followNetworkSwitch();
});
// A switch saved since loadState() above, before there was a listener.
followNetworkSwitch();
if (!state.hasWallet) {
showView("welcome");
} else {
+2 -2
View File
@@ -39,8 +39,8 @@ function markViewRendered(view) {
}
// Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one when the user switches the network, because every screen
// then on the page shows the previous network (views/settings.js); the
// asks for one after a network switch, because every screen then on the page
// shows the previous network (showNewNetwork() in views/settings.js); the
// unit tests, which simulate several page loads against one module
// instance, ask for one too.
function resetRenderedViews() {
+61 -28
View File
@@ -38,6 +38,8 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0;
let versionClickTimer = null;
@@ -243,7 +245,51 @@ async function renderSiteLists() {
);
}
function init(ctx) {
// A network switch, made by the user here or saved by the background once the
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. These two leave the page as README's
// Screen Map describes, the first before the switch is saved, so that save
// keeps the cut for a popup closed straight after, the second once `state`
// holds the new network.
//
// The stack is cut at the first screen that cannot be rendered from saved
// state, such as Send or AddToken, and at AddressToken unless it shows the
// native token: any other token there was picked on the previous network, so
// it is no longer selected either. Without a wallet the stack starts at
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
function cutViewStack() {
if (!state.hasWallet) return;
if (state.selectedToken !== "ETH") {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1) {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.selectedToken = null;
}
state.viewStack = restorableStack(state.viewStack, state.currentView);
}
// Every screen counts as not yet rendered, so "Back" renders the one it lands
// on for the new network. Settings on top is brought up to date in place;
// any other screen is drawn again as a reopened popup draws it, which is Home
// for one that cannot be rendered from saved state. Without a wallet any other
// screen is Welcome or AddWallet, which show nothing from a network.
function showNewNetwork() {
resetRenderedViews();
if (state.currentView === "settings") {
markViewRendered("settings");
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
} else if (state.hasWallet) {
ctx.restoreView();
}
updateDebugBanner(state.currentView);
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx);
showPhrase.init();
@@ -319,37 +365,18 @@ function init(ctx) {
const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value;
if (newId === state.networkId) return;
// The screens beneath Settings show the previous network, so Back
// finds them as it does in a reopened popup: each is rendered again
// when Back lands on it, and the stack is cut at the first one that
// cannot be rendered from saved state, such as Send or AddToken. It
// is cut at AddressToken too, unless that shows the native token: any
// other token there was picked on the previous network. Without a
// wallet the stack starts at Welcome, which that cut would drop, and
// Welcome shows nothing from a network. The cut comes before the
// switch so that the switch's save keeps it for a popup closed
// straight after.
if (state.hasWallet) {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1 && state.selectedToken !== "ETH") {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.viewStack = restorableStack(state.viewStack, "settings");
}
cutViewStack();
const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showNewNetwork();
showFlash("Switched to " + net.name + ".");
});
@@ -484,4 +511,10 @@ function init(ctx) {
});
}
module.exports = { init, show, renderSiteLists };
module.exports = {
init,
show,
renderSiteLists,
cutViewStack,
showNewNetwork,
};